Re-implement basic novel view functionality
This commit is contained in:
@@ -123,7 +123,7 @@ class Index:
|
||||
def _generateNovelModel(self, tHandle: str) -> None:
|
||||
"""Generate a novel model for a specific handle."""
|
||||
if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike():
|
||||
model = NovelModel(item)
|
||||
model = NovelModel()
|
||||
for handle in SHARED.project.tree.subTree(tHandle):
|
||||
if node := self._itemIndex[handle]:
|
||||
model.append(node)
|
||||
|
||||
@@ -31,37 +31,45 @@ from PyQt6.QtGui import QIcon, QPixmap
|
||||
from novelwriter import SHARED
|
||||
from novelwriter.constants import nwStyles
|
||||
from novelwriter.core.indexdata import IndexNode
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.types import QtAlignRight
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
C_FACTOR = 0x0100
|
||||
|
||||
C_TITLE_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
|
||||
C_TITLE_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
|
||||
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole
|
||||
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
|
||||
C_EXTRA_TEXT = 0x0200 | Qt.ItemDataRole.DisplayRole
|
||||
C_EXTRA_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
|
||||
C_MORE_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
|
||||
R_TEXT = Qt.ItemDataRole.DisplayRole
|
||||
R_ICON = Qt.ItemDataRole.DecorationRole
|
||||
R_ALIGN = Qt.ItemDataRole.TextAlignmentRole
|
||||
R_TIP = Qt.ItemDataRole.ToolTipRole
|
||||
|
||||
T_NodeData = str | QIcon | QPixmap | Qt.AlignmentFlag | None
|
||||
|
||||
|
||||
class NovelModel(QAbstractTableModel):
|
||||
|
||||
def __init__(self, rootItem: NWItem) -> None:
|
||||
__slots__ = ("_meta", "_rows", "_more", "_columns")
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._root = rootItem
|
||||
self._rows: list[tuple[str, str, dict]] = []
|
||||
self._meta: list[tuple[str, str]] = []
|
||||
self._rows: list[dict[int, T_NodeData]] = []
|
||||
self._more = SHARED.theme.getIcon("more_arrow")
|
||||
self._columns = 3
|
||||
return
|
||||
|
||||
def __del__(self) -> None: # pragma: no cover
|
||||
logger.debug("Delete: NovelModel")
|
||||
return
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setExtraColumn(self, state: bool) -> None:
|
||||
"""Set extra data column settings."""
|
||||
self._columns = 4 if state else 3
|
||||
return
|
||||
|
||||
##
|
||||
# Model Interface
|
||||
##
|
||||
@@ -72,29 +80,42 @@ class NovelModel(QAbstractTableModel):
|
||||
|
||||
def columnCount(self, index: QModelIndex) -> int:
|
||||
"""Return the number of columns for an entry."""
|
||||
return 4
|
||||
return self._columns
|
||||
|
||||
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
|
||||
"""Return display data for a node."""
|
||||
if index.isValid() and (row := index.row()) < len(self._rows):
|
||||
return self._rows[row][2].get(C_FACTOR*index.column() | role)
|
||||
return self._rows[row].get(C_FACTOR*index.column() | role)
|
||||
return None
|
||||
|
||||
def keys(self, index: QModelIndex) -> tuple[str | None, str | None]:
|
||||
"""Return display data for a node."""
|
||||
if index.isValid() and (row := index.row()) < len(self._rows):
|
||||
return self._meta[row]
|
||||
return None, None
|
||||
|
||||
##
|
||||
# Data Methods
|
||||
##
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the model."""
|
||||
self._rows.clear()
|
||||
return
|
||||
|
||||
def append(self, node: IndexNode) -> None:
|
||||
"""Append a node to the model."""
|
||||
handle = node.handle
|
||||
for key, head in node.items():
|
||||
if key != "T0000":
|
||||
iLevel = nwStyles.H_LEVEL.get(head.level, 0)
|
||||
more = self._columns - 1
|
||||
data = {}
|
||||
data[C_TITLE_TEXT] = head.title
|
||||
data[C_TITLE_ICON] = SHARED.theme.getHeaderDecoration(iLevel)
|
||||
data[C_COUNT_TEXT] = f"{head.mainCount:n}"
|
||||
data[C_COUNT_ALIGN] = QtAlignRight
|
||||
data[C_MORE_ICON] = self._more
|
||||
self._rows.append((handle, key, data))
|
||||
data[C_FACTOR*0 | R_TEXT] = head.title
|
||||
data[C_FACTOR*0 | R_ICON] = SHARED.theme.getHeaderDecoration(iLevel)
|
||||
data[C_FACTOR*1 | R_TEXT] = f"{head.mainCount:n}"
|
||||
data[C_FACTOR*1 | R_ALIGN] = QtAlignRight
|
||||
data[C_FACTOR*more | R_ICON] = self._more
|
||||
self._meta.append((handle, key))
|
||||
self._rows.append(data)
|
||||
return
|
||||
|
||||
@@ -30,15 +30,15 @@ from __future__ import annotations
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt6.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt6.QtCore import QModelIndex, QSize, Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt6.QtGui import QMouseEvent, QWheelEvent
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox,
|
||||
QToolButton, QWidget
|
||||
QToolButton, QTreeView, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.types import QtMouseLeft
|
||||
from novelwriter.types import QtMouseLeft, QtMouseMiddle
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from novelwriter.guimain import GuiMain
|
||||
@@ -99,6 +99,17 @@ class NNonBlockingDialog(NDialog):
|
||||
return
|
||||
|
||||
|
||||
class NTreeView(QTreeView):
|
||||
|
||||
middleClicked = pyqtSignal(QModelIndex)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent | None) -> None:
|
||||
"""Emit a signal on mouse middle click."""
|
||||
if event and event.button() == QtMouseMiddle:
|
||||
self.middleClicked.emit(self.indexAt(event.pos()))
|
||||
return super().mousePressEvent(event)
|
||||
|
||||
|
||||
class NComboBox(QComboBox):
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
|
||||
@@ -29,17 +29,15 @@ import logging
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt6.QtCore import QModelIndex, pyqtSignal, pyqtSlot
|
||||
from PyQt6.QtGui import QActionGroup, QFont, QPalette
|
||||
from PyQt6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QMenu, QTreeView, QTreeWidgetItem, QVBoxLayout,
|
||||
QWidget
|
||||
)
|
||||
from PyQt6.QtWidgets import QAbstractItemView, QFrame, QHBoxLayout, QMenu, QVBoxLayout, QWidget
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import qtAddAction, qtAddMenu, qtLambda
|
||||
from novelwriter.enum import nwChange, nwItemClass
|
||||
from novelwriter.extensions.modified import NIconToolButton
|
||||
from novelwriter.core.novelmodel import NovelModel
|
||||
from novelwriter.enum import nwChange, nwDocMode, nwItemClass
|
||||
from novelwriter.extensions.modified import NIconToolButton, NTreeView
|
||||
from novelwriter.extensions.novelselector import NovelSelector
|
||||
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
|
||||
from novelwriter.types import (
|
||||
@@ -93,7 +91,6 @@ class GuiNovelView(QWidget):
|
||||
def updateTheme(self) -> None:
|
||||
"""Update theme elements."""
|
||||
self.novelBar.updateTheme()
|
||||
self.novelTree.updateTheme()
|
||||
self.refreshTree()
|
||||
return
|
||||
|
||||
@@ -104,7 +101,7 @@ class GuiNovelView(QWidget):
|
||||
|
||||
def clearNovelView(self) -> None:
|
||||
"""Clear project-related GUI content."""
|
||||
self.novelTree.clearContent()
|
||||
# self.novelTree.clearContent()
|
||||
self.novelBar.clearContent()
|
||||
self.novelBar.setEnabled(False)
|
||||
return
|
||||
@@ -351,7 +348,7 @@ class GuiNovelToolBar(QWidget):
|
||||
return
|
||||
|
||||
|
||||
class GuiNovelTree(QTreeView):
|
||||
class GuiNovelTree(NTreeView):
|
||||
|
||||
C_DATA = 0
|
||||
C_TITLE = 0
|
||||
@@ -372,68 +369,36 @@ class GuiNovelTree(QTreeView):
|
||||
self.novelView = novelView
|
||||
|
||||
# Internal Variables
|
||||
self._lastBuild = 0
|
||||
self._lastCol = NovelTreeColumn.POV
|
||||
self._lastColSize = 0.25
|
||||
self._actHandle = None
|
||||
self._treeMap: dict[str, QTreeWidgetItem] = {}
|
||||
# self._lastBuild = 0
|
||||
# self._lastCol = NovelTreeColumn.POV
|
||||
# self._lastColSize = 0.25
|
||||
# self._actHandle = None
|
||||
# self._treeMap: dict[str, QTreeWidgetItem] = {}
|
||||
|
||||
# Cached Strings
|
||||
# self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
|
||||
# self._focLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])
|
||||
# self._pltLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])
|
||||
|
||||
# Build GUI
|
||||
# =========
|
||||
|
||||
# iPx = SHARED.theme.baseIconHeight
|
||||
|
||||
self.setIconSize(SHARED.theme.baseIconSize)
|
||||
self.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
self.setUniformRowHeights(True)
|
||||
self.setAllColumnsShowFocus(True)
|
||||
self.setHeaderHidden(True)
|
||||
self.setIndentation(2)
|
||||
self.setDragEnabled(False)
|
||||
|
||||
# self.setIconSize(iSz)
|
||||
# self.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
# self.setUniformRowHeights(True)
|
||||
# self.setAllColumnsShowFocus(True)
|
||||
# self.setHeaderHidden(True)
|
||||
# self.setIndentation(2)
|
||||
# self.setColumnCount(4)
|
||||
# self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
# self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
# self.setExpandsOnDoubleClick(False)
|
||||
# self.setDragEnabled(False)
|
||||
|
||||
# Lock the column sizes
|
||||
# if header := self.header():
|
||||
# header.setStretchLastSection(False)
|
||||
# header.setMinimumSectionSize(iPx + 6)
|
||||
# header.setSectionResizeMode(self.C_TITLE, QtHeaderStretch)
|
||||
# header.setSectionResizeMode(self.C_WORDS, QtHeaderToContents)
|
||||
# header.setSectionResizeMode(self.C_EXTRA, QtHeaderToContents)
|
||||
# header.setSectionResizeMode(self.C_MORE, QtHeaderToContents)
|
||||
|
||||
# Pre-Generate Tree Formatting
|
||||
# fH1 = self.font()
|
||||
# fH1.setBold(True)
|
||||
# fH1.setUnderline(True)
|
||||
|
||||
# fH2 = self.font()
|
||||
# fH2.setBold(True)
|
||||
|
||||
# self._hFonts = [self.font(), fH1, fH2, self.font(), self.font()]
|
||||
# Set selection options
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
|
||||
# Connect signals
|
||||
# self.clicked.connect(self._treeItemClicked)
|
||||
# self.itemDoubleClicked.connect(self._treeDoubleClick)
|
||||
# self.itemSelectionChanged.connect(self._treeSelectionChange)
|
||||
self.clicked.connect(self._onSingleClick)
|
||||
self.doubleClicked.connect(self._onDoubleClick)
|
||||
self.middleClicked.connect(self._onMiddleClick)
|
||||
|
||||
# Set custom settings
|
||||
self.initSettings()
|
||||
# self.updateTheme()
|
||||
|
||||
logger.debug("Ready: GuiNovelTree")
|
||||
|
||||
@@ -441,25 +406,87 @@ class GuiNovelTree(QTreeView):
|
||||
|
||||
def initSettings(self) -> None:
|
||||
"""Set or update tree widget settings."""
|
||||
# Scroll bars
|
||||
if CONFIG.hideVScroll:
|
||||
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
|
||||
if CONFIG.hideHScroll:
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
|
||||
return
|
||||
|
||||
def updateTheme(self) -> None:
|
||||
"""Update theme elements."""
|
||||
# iPx = SHARED.theme.baseIconHeight
|
||||
# self._pMore = SHARED.theme.getPixmap("more_arrow", (iPx, iPx))
|
||||
##
|
||||
# Getters
|
||||
##
|
||||
|
||||
def getSelectedHandle(self) -> tuple[str | None, str | None]:
|
||||
"""Get the currently selected or active handle. If multiple
|
||||
items are selected, return the first.
|
||||
"""
|
||||
if model := self._getModel():
|
||||
return model.keys(self.currentIndex())
|
||||
return None, None
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def resizeColumsn(self) -> None:
|
||||
"""Set the correct column sizes."""
|
||||
if (header := self.header()) and (model := self._getModel()):
|
||||
header.setStretchLastSection(False)
|
||||
header.setMinimumSectionSize(SHARED.theme.baseIconHeight + 6)
|
||||
header.setSectionResizeMode(0, QtHeaderStretch)
|
||||
for i in range(1, model.columnCount(QModelIndex())):
|
||||
header.setSectionResizeMode(i, QtHeaderToContents)
|
||||
return
|
||||
|
||||
##
|
||||
# Private Slots
|
||||
##
|
||||
|
||||
@pyqtSlot(QModelIndex)
|
||||
def _onSingleClick(self, index: QModelIndex) -> None:
|
||||
"""The user single-clicked an index."""
|
||||
if (model := self._getModel()) and (keys := model.keys(index)) and (tHandle := keys[0]):
|
||||
self.novelView.selectedItemChanged.emit(tHandle)
|
||||
return
|
||||
|
||||
@pyqtSlot(QModelIndex)
|
||||
def _onDoubleClick(self, index: QModelIndex) -> None:
|
||||
"""The user double-clicked an index."""
|
||||
if (
|
||||
(model := self._getModel()) and (keys := model.keys(index))
|
||||
and (tHandle := keys[0]) and (sTitle := keys[1])
|
||||
):
|
||||
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", False)
|
||||
return
|
||||
|
||||
@pyqtSlot(QModelIndex)
|
||||
def _onMiddleClick(self, index: QModelIndex) -> None:
|
||||
"""The user middle-clicked an index."""
|
||||
if (
|
||||
(model := self._getModel()) and (keys := model.keys(index))
|
||||
and (tHandle := keys[0]) and (sTitle := keys[1])
|
||||
):
|
||||
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle or "", False)
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _getModel(self) -> NovelModel | None:
|
||||
"""Return the model, if it exists."""
|
||||
if isinstance(model := self.model(), NovelModel):
|
||||
return model
|
||||
return None
|
||||
|
||||
##
|
||||
# Old Code
|
||||
##
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
@@ -476,23 +503,12 @@ class GuiNovelTree(QTreeView):
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def clearContent(self) -> None:
|
||||
"""Clear the GUI content and the related maps."""
|
||||
# self.clear()
|
||||
# self._treeMap = {}
|
||||
# self._lastBuild = 0
|
||||
return
|
||||
|
||||
def resizeColumsn(self) -> None:
|
||||
"""Set the correct column sizes."""
|
||||
if header := self.header():
|
||||
header.setStretchLastSection(False)
|
||||
header.setMinimumSectionSize(SHARED.theme.baseIconHeight + 6)
|
||||
header.setSectionResizeMode(self.C_TITLE, QtHeaderStretch)
|
||||
header.setSectionResizeMode(self.C_WORDS, QtHeaderToContents)
|
||||
header.setSectionResizeMode(self.C_EXTRA, QtHeaderToContents)
|
||||
header.setSectionResizeMode(self.C_MORE, QtHeaderToContents)
|
||||
return
|
||||
# def clearContent(self) -> None:
|
||||
# """Clear the GUI content and the related maps."""
|
||||
# self.clear()
|
||||
# self._treeMap = {}
|
||||
# self._lastBuild = 0
|
||||
# return
|
||||
|
||||
# def refreshTree(self, rootHandle: str | None = None, overRide: bool = False) -> None:
|
||||
# """Refresh the tree if it has been changed."""
|
||||
@@ -526,18 +542,6 @@ class GuiNovelTree(QTreeView):
|
||||
# return
|
||||
# return
|
||||
|
||||
def getSelectedHandle(self) -> tuple[str | None, str | None]:
|
||||
"""Get the currently selected or active handle. If multiple
|
||||
items are selected, return the first.
|
||||
"""
|
||||
# selList = self.selectedItems()
|
||||
# trItem = selList[0] if selList else self.currentItem()
|
||||
# if isinstance(trItem, QTreeWidgetItem):
|
||||
# tHandle = trItem.data(self.C_DATA, self.D_HANDLE)
|
||||
# sTitle = trItem.data(self.C_DATA, self.D_TITLE)
|
||||
# return tHandle, sTitle
|
||||
return None, None
|
||||
|
||||
# def setLastColType(self, colType: NovelTreeColumn, doRefresh: bool = True) -> None:
|
||||
# """Change the content type of the last column and rebuild."""
|
||||
# if self._lastCol != colType:
|
||||
@@ -575,9 +579,9 @@ class GuiNovelTree(QTreeView):
|
||||
# self._actHandle = tHandle or None
|
||||
# return
|
||||
|
||||
# ##
|
||||
# # Events
|
||||
# ##
|
||||
##
|
||||
# Events
|
||||
##
|
||||
|
||||
# def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
# """Overload mousePressEvent to clear selection if clicking the
|
||||
|
||||
@@ -1022,7 +1022,7 @@ class GuiProjectTree(QTreeView):
|
||||
return [i for i in self.selectedIndexes() if i.column() == 0]
|
||||
|
||||
def _getModel(self) -> ProjectModel | None:
|
||||
"""Return a project node corresponding to a model index."""
|
||||
"""Return the model, if it exists."""
|
||||
if isinstance(model := self.model(), ProjectModel):
|
||||
return model
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user