Re-implement basic novel view functionality

This commit is contained in:
Veronica Berglyd Olsen
2025-02-22 21:54:24 +01:00
parent 9a63a84fc1
commit 71ad7c3229
5 changed files with 153 additions and 117 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ class Index:
def _generateNovelModel(self, tHandle: str) -> None: def _generateNovelModel(self, tHandle: str) -> None:
"""Generate a novel model for a specific handle.""" """Generate a novel model for a specific handle."""
if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): 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): for handle in SHARED.project.tree.subTree(tHandle):
if node := self._itemIndex[handle]: if node := self._itemIndex[handle]:
model.append(node) model.append(node)
+40 -19
View File
@@ -31,37 +31,45 @@ from PyQt6.QtGui import QIcon, QPixmap
from novelwriter import SHARED from novelwriter import SHARED
from novelwriter.constants import nwStyles from novelwriter.constants import nwStyles
from novelwriter.core.indexdata import IndexNode from novelwriter.core.indexdata import IndexNode
from novelwriter.core.item import NWItem
from novelwriter.types import QtAlignRight from novelwriter.types import QtAlignRight
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
C_FACTOR = 0x0100 C_FACTOR = 0x0100
C_TITLE_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole R_TEXT = Qt.ItemDataRole.DisplayRole
C_TITLE_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole R_ICON = Qt.ItemDataRole.DecorationRole
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole R_ALIGN = Qt.ItemDataRole.TextAlignmentRole
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole R_TIP = Qt.ItemDataRole.ToolTipRole
C_EXTRA_TEXT = 0x0200 | Qt.ItemDataRole.DisplayRole
C_EXTRA_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
C_MORE_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
T_NodeData = str | QIcon | QPixmap | Qt.AlignmentFlag | None T_NodeData = str | QIcon | QPixmap | Qt.AlignmentFlag | None
class NovelModel(QAbstractTableModel): class NovelModel(QAbstractTableModel):
def __init__(self, rootItem: NWItem) -> None: __slots__ = ("_meta", "_rows", "_more", "_columns")
def __init__(self) -> None:
super().__init__() super().__init__()
self._root = rootItem self._meta: list[tuple[str, str]] = []
self._rows: list[tuple[str, str, dict]] = [] self._rows: list[dict[int, T_NodeData]] = []
self._more = SHARED.theme.getIcon("more_arrow") self._more = SHARED.theme.getIcon("more_arrow")
self._columns = 3
return return
def __del__(self) -> None: # pragma: no cover def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: NovelModel") logger.debug("Delete: NovelModel")
return return
##
# Setters
##
def setExtraColumn(self, state: bool) -> None:
"""Set extra data column settings."""
self._columns = 4 if state else 3
return
## ##
# Model Interface # Model Interface
## ##
@@ -72,29 +80,42 @@ class NovelModel(QAbstractTableModel):
def columnCount(self, index: QModelIndex) -> int: def columnCount(self, index: QModelIndex) -> int:
"""Return the number of columns for an entry.""" """Return the number of columns for an entry."""
return 4 return self._columns
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData: def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
"""Return display data for a node.""" """Return display data for a node."""
if index.isValid() and (row := index.row()) < len(self._rows): 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 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 # Data Methods
## ##
def clear(self) -> None:
"""Clear the model."""
self._rows.clear()
return
def append(self, node: IndexNode) -> None: def append(self, node: IndexNode) -> None:
"""Append a node to the model.""" """Append a node to the model."""
handle = node.handle handle = node.handle
for key, head in node.items(): for key, head in node.items():
if key != "T0000": if key != "T0000":
iLevel = nwStyles.H_LEVEL.get(head.level, 0) iLevel = nwStyles.H_LEVEL.get(head.level, 0)
more = self._columns - 1
data = {} data = {}
data[C_TITLE_TEXT] = head.title data[C_FACTOR*0 | R_TEXT] = head.title
data[C_TITLE_ICON] = SHARED.theme.getHeaderDecoration(iLevel) data[C_FACTOR*0 | R_ICON] = SHARED.theme.getHeaderDecoration(iLevel)
data[C_COUNT_TEXT] = f"{head.mainCount:n}" data[C_FACTOR*1 | R_TEXT] = f"{head.mainCount:n}"
data[C_COUNT_ALIGN] = QtAlignRight data[C_FACTOR*1 | R_ALIGN] = QtAlignRight
data[C_MORE_ICON] = self._more data[C_FACTOR*more | R_ICON] = self._more
self._rows.append((handle, key, data)) self._meta.append((handle, key))
self._rows.append(data)
return return
+14 -3
View File
@@ -30,15 +30,15 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING 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.QtGui import QMouseEvent, QWheelEvent
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox, QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox,
QToolButton, QWidget QToolButton, QTreeView, QWidget
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.types import QtMouseLeft from novelwriter.types import QtMouseLeft, QtMouseMiddle
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain from novelwriter.guimain import GuiMain
@@ -99,6 +99,17 @@ class NNonBlockingDialog(NDialog):
return 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): class NComboBox(QComboBox):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
+97 -93
View File
@@ -29,17 +29,15 @@ import logging
from enum import Enum 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.QtGui import QActionGroup, QFont, QPalette
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import QAbstractItemView, QFrame, QHBoxLayout, QMenu, QVBoxLayout, QWidget
QFrame, QHBoxLayout, QMenu, QTreeView, QTreeWidgetItem, QVBoxLayout,
QWidget
)
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import qtAddAction, qtAddMenu, qtLambda from novelwriter.common import qtAddAction, qtAddMenu, qtLambda
from novelwriter.enum import nwChange, nwItemClass from novelwriter.core.novelmodel import NovelModel
from novelwriter.extensions.modified import NIconToolButton from novelwriter.enum import nwChange, nwDocMode, nwItemClass
from novelwriter.extensions.modified import NIconToolButton, NTreeView
from novelwriter.extensions.novelselector import NovelSelector from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import ( from novelwriter.types import (
@@ -93,7 +91,6 @@ class GuiNovelView(QWidget):
def updateTheme(self) -> None: def updateTheme(self) -> None:
"""Update theme elements.""" """Update theme elements."""
self.novelBar.updateTheme() self.novelBar.updateTheme()
self.novelTree.updateTheme()
self.refreshTree() self.refreshTree()
return return
@@ -104,7 +101,7 @@ class GuiNovelView(QWidget):
def clearNovelView(self) -> None: def clearNovelView(self) -> None:
"""Clear project-related GUI content.""" """Clear project-related GUI content."""
self.novelTree.clearContent() # self.novelTree.clearContent()
self.novelBar.clearContent() self.novelBar.clearContent()
self.novelBar.setEnabled(False) self.novelBar.setEnabled(False)
return return
@@ -351,7 +348,7 @@ class GuiNovelToolBar(QWidget):
return return
class GuiNovelTree(QTreeView): class GuiNovelTree(NTreeView):
C_DATA = 0 C_DATA = 0
C_TITLE = 0 C_TITLE = 0
@@ -372,68 +369,36 @@ class GuiNovelTree(QTreeView):
self.novelView = novelView self.novelView = novelView
# Internal Variables # Internal Variables
self._lastBuild = 0 # self._lastBuild = 0
self._lastCol = NovelTreeColumn.POV # self._lastCol = NovelTreeColumn.POV
self._lastColSize = 0.25 # self._lastColSize = 0.25
self._actHandle = None # self._actHandle = None
self._treeMap: dict[str, QTreeWidgetItem] = {} # self._treeMap: dict[str, QTreeWidgetItem] = {}
# Cached Strings # Cached Strings
# self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]) # self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
# self._focLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]) # self._focLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])
# self._pltLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]) # self._pltLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])
# Build GUI
# =========
# iPx = SHARED.theme.baseIconHeight
self.setIconSize(SHARED.theme.baseIconSize) self.setIconSize(SHARED.theme.baseIconSize)
self.setFrameStyle(QFrame.Shape.NoFrame) self.setFrameStyle(QFrame.Shape.NoFrame)
self.setUniformRowHeights(True) self.setUniformRowHeights(True)
self.setAllColumnsShowFocus(True) self.setAllColumnsShowFocus(True)
self.setHeaderHidden(True) self.setHeaderHidden(True)
self.setIndentation(2) self.setIndentation(2)
self.setDragEnabled(False)
# self.setIconSize(iSz) # Set selection options
# self.setFrameStyle(QFrame.Shape.NoFrame) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# self.setUniformRowHeights(True) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
# 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()]
# Connect signals # Connect signals
# self.clicked.connect(self._treeItemClicked) self.clicked.connect(self._onSingleClick)
# self.itemDoubleClicked.connect(self._treeDoubleClick) self.doubleClicked.connect(self._onDoubleClick)
# self.itemSelectionChanged.connect(self._treeSelectionChange) self.middleClicked.connect(self._onMiddleClick)
# Set custom settings # Set custom settings
self.initSettings() self.initSettings()
# self.updateTheme()
logger.debug("Ready: GuiNovelTree") logger.debug("Ready: GuiNovelTree")
@@ -441,25 +406,87 @@ class GuiNovelTree(QTreeView):
def initSettings(self) -> None: def initSettings(self) -> None:
"""Set or update tree widget settings.""" """Set or update tree widget settings."""
# Scroll bars
if CONFIG.hideVScroll: if CONFIG.hideVScroll:
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff) self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
else: else:
self.setVerticalScrollBarPolicy(QtScrollAsNeeded) self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
if CONFIG.hideHScroll: if CONFIG.hideHScroll:
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff) self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
else: else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded) self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
return return
def updateTheme(self) -> None: ##
"""Update theme elements.""" # Getters
# iPx = SHARED.theme.baseIconHeight ##
# self._pMore = SHARED.theme.getPixmap("more_arrow", (iPx, iPx))
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 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 # Properties
## ##
@@ -476,23 +503,12 @@ class GuiNovelTree(QTreeView):
# Class Methods # Class Methods
## ##
def clearContent(self) -> None: # def clearContent(self) -> None:
"""Clear the GUI content and the related maps.""" # """Clear the GUI content and the related maps."""
# self.clear() # self.clear()
# self._treeMap = {} # self._treeMap = {}
# self._lastBuild = 0 # self._lastBuild = 0
return # 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 refreshTree(self, rootHandle: str | None = None, overRide: bool = False) -> None: # def refreshTree(self, rootHandle: str | None = None, overRide: bool = False) -> None:
# """Refresh the tree if it has been changed.""" # """Refresh the tree if it has been changed."""
@@ -526,18 +542,6 @@ class GuiNovelTree(QTreeView):
# return # return
# 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: # def setLastColType(self, colType: NovelTreeColumn, doRefresh: bool = True) -> None:
# """Change the content type of the last column and rebuild.""" # """Change the content type of the last column and rebuild."""
# if self._lastCol != colType: # if self._lastCol != colType:
@@ -575,9 +579,9 @@ class GuiNovelTree(QTreeView):
# self._actHandle = tHandle or None # self._actHandle = tHandle or None
# return # return
# ## ##
# # Events # Events
# ## ##
# def mousePressEvent(self, event: QMouseEvent) -> None: # def mousePressEvent(self, event: QMouseEvent) -> None:
# """Overload mousePressEvent to clear selection if clicking the # """Overload mousePressEvent to clear selection if clicking the
+1 -1
View File
@@ -1022,7 +1022,7 @@ class GuiProjectTree(QTreeView):
return [i for i in self.selectedIndexes() if i.column() == 0] return [i for i in self.selectedIndexes() if i.column() == 0]
def _getModel(self) -> ProjectModel | None: 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): if isinstance(model := self.model(), ProjectModel):
return model return model
return None return None