diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 4419dca0..0adbe3c1 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -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) diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index 6aac5552..caa7c977 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -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 diff --git a/novelwriter/extensions/modified.py b/novelwriter/extensions/modified.py index 67aa7453..ea0dc103 100644 --- a/novelwriter/extensions/modified.py +++ b/novelwriter/extensions/modified.py @@ -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: diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 5bb709ce..4d315731 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -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 diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 46ae15f5..877425ec 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -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