""" novelWriter – GUI Novel Tree ============================ File History: Created: 2020-12-20 [1.1rc1] GuiNovelTree Created: 2022-06-12 [2.0rc1] GuiNovelView Created: 2022-06-12 [2.0rc1] GuiNovelToolBar This file is a part of novelWriter Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ from __future__ import annotations import logging from enum import Enum from time import time from PyQt6.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot from PyQt6.QtGui import QActionGroup, QFocusEvent, QFont, QMouseEvent, QPalette, QResizeEvent from PyQt6.QtWidgets import ( QAbstractItemView, QFrame, QHBoxLayout, QInputDialog, QMenu, QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter import CONFIG, SHARED from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst from novelwriter.core.index import IndexHeading from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline from novelwriter.extensions.modified import NIconToolButton from novelwriter.extensions.novelselector import NovelSelector from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON from novelwriter.types import ( QtAlignRight, QtDecoration, QtHeaderStretch, QtHeaderToContents, QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding, QtUserRole ) logger = logging.getLogger(__name__) class NovelTreeColumn(Enum): HIDDEN = 0 POV = 1 FOCUS = 2 PLOT = 3 class GuiNovelView(QWidget): # Signals for user interaction with the novel tree selectedItemChanged = pyqtSignal(str) openDocumentRequest = pyqtSignal(str, Enum, str, bool) def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) # Build GUI self.novelTree = GuiNovelTree(self) self.novelBar = GuiNovelToolBar(self) self.novelBar.setEnabled(False) # Assemble self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.novelBar, 0) self.outerBox.addWidget(self.novelTree, 1) self.outerBox.setContentsMargins(0, 0, 0, 0) self.outerBox.setSpacing(0) self.setLayout(self.outerBox) # Function Mappings self.getSelectedHandle = self.novelTree.getSelectedHandle return ## # Methods ## def updateTheme(self) -> None: """Update theme elements.""" self.novelBar.updateTheme() self.novelTree.updateTheme() self.refreshTree() return def initSettings(self) -> None: """Initialise GUI elements that depend on specific settings.""" self.novelTree.initSettings() return def clearNovelView(self) -> None: """Clear project-related GUI content.""" self.novelTree.clearContent() self.novelBar.clearContent() self.novelBar.setEnabled(False) return def openProjectTasks(self) -> None: """Run open project tasks.""" lastNovel = SHARED.project.data.getLastHandle("novelTree") if lastNovel and lastNovel not in SHARED.project.tree: lastNovel = SHARED.project.tree.findRoot(nwItemClass.NOVEL) logger.debug("Setting novel tree to root item '%s'", lastNovel) lastCol = SHARED.project.options.getEnum( "GuiNovelView", "lastCol", NovelTreeColumn, NovelTreeColumn.HIDDEN ) lastColSize = SHARED.project.options.getInt( "GuiNovelView", "lastColSize", 25 ) self.clearNovelView() self.novelBar.buildNovelRootMenu() self.novelBar.setLastColType(lastCol, doRefresh=False) self.novelBar.setCurrentRoot(lastNovel) self.novelBar.setEnabled(True) self.novelTree.setLastColSize(lastColSize) return def closeProjectTasks(self) -> None: """Run closing project tasks.""" lastColType = self.novelTree.lastColType lastColSize = self.novelTree.lastColSize logger.debug("Saving State: GuiNovelView") pOptions = SHARED.project.options pOptions.setValue("GuiNovelView", "lastCol", lastColType) pOptions.setValue("GuiNovelView", "lastColSize", lastColSize) self.clearNovelView() return def setTreeFocus(self) -> None: """Set the focus to the tree widget.""" self.novelTree.setFocus() return def treeHasFocus(self) -> bool: """Check if the novel tree has focus.""" return self.novelTree.hasFocus() ## # Public Slots ## @pyqtSlot(str) def setActiveHandle(self, tHandle: str) -> None: """Highlight the rows associated with a given handle.""" self.novelTree.setActiveHandle(tHandle) return @pyqtSlot() def refreshTree(self) -> None: """Refresh the current tree.""" self.novelTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("novelTree")) return @pyqtSlot(str, Enum) def updateRootItem(self, tHandle: str, change: nwChange) -> None: """If any root item changes, rebuild the novel root menu.""" self.novelBar.buildNovelRootMenu() return @pyqtSlot(str) def updateNovelItemMeta(self, tHandle: str) -> None: """The meta data of a novel item has changed, and the tree item needs to be refreshed. """ self.novelTree.refreshHandle(tHandle) return class GuiNovelToolBar(QWidget): def __init__(self, novelView: GuiNovelView) -> None: super().__init__(parent=novelView) logger.debug("Create: GuiNovelToolBar") self.novelView = novelView iSz = SHARED.theme.baseIconSize self.setContentsMargins(0, 0, 0, 0) self.setAutoFillBackground(True) # Novel Selector selFont = self.font() selFont.setWeight(QFont.Weight.Bold) self.novelValue = NovelSelector(self) self.novelValue.setFont(selFont) self.novelValue.setListFormat(self.tr("Outline of {0}")) self.novelValue.setMinimumWidth(150) self.novelValue.setSizePolicy(QtSizeExpanding, QtSizeExpanding) self.novelValue.novelSelectionChanged.connect(self.setCurrentRoot) self.tbNovel = NIconToolButton(self, iSz) self.tbNovel.setToolTip(self.tr("Novel Root")) self.tbNovel.clicked.connect(self.novelValue.showPopup) # Refresh Button self.tbRefresh = NIconToolButton(self, iSz) self.tbRefresh.setToolTip(self.tr("Refresh")) self.tbRefresh.clicked.connect(self._refreshNovelTree) # More Options Menu self.mMore = QMenu(self) self.mLastCol = qtAddMenu(self.mMore, self.tr("Last Column")) self.gLastCol = QActionGroup(self.mMore) self.aLastCol = {} self._addLastColAction(NovelTreeColumn.HIDDEN, self.tr("Hidden")) self._addLastColAction(NovelTreeColumn.POV, self.tr("Point of View Character")) self._addLastColAction(NovelTreeColumn.FOCUS, self.tr("Focus Character")) self._addLastColAction(NovelTreeColumn.PLOT, self.tr("Novel Plot")) self.mLastCol.addSeparator() self.aLastColSize = qtAddAction(self.mLastCol, self.tr("Column Size")) self.aLastColSize.triggered.connect(self._selectLastColumnSize) self.tbMore = NIconToolButton(self, iSz) self.tbMore.setToolTip(self.tr("More Options")) self.tbMore.setMenu(self.mMore) # Assemble self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.novelValue) self.outerBox.addWidget(self.tbNovel) self.outerBox.addWidget(self.tbRefresh) self.outerBox.addWidget(self.tbMore) self.outerBox.setContentsMargins(2, 2, 0, 2) self.outerBox.setSpacing(0) self.setLayout(self.outerBox) self.updateTheme() logger.debug("Ready: GuiNovelToolBar") return ## # Methods ## def updateTheme(self) -> None: """Update theme elements.""" # Icons self.tbNovel.setThemeIcon("cls_novel", "red") self.tbRefresh.setThemeIcon("refresh", "green") self.tbMore.setThemeIcon("more_vertical") qPalette = self.palette() qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base()) self.setPalette(qPalette) # StyleSheets buttonStyle = SHARED.theme.getStyleSheet(STYLES_MIN_TOOLBUTTON) self.tbNovel.setStyleSheet(buttonStyle) self.tbRefresh.setStyleSheet(buttonStyle) self.tbMore.setStyleSheet(buttonStyle) self.novelValue.setStyleSheet( "QComboBox {border-style: none; padding-left: 0;} " "QComboBox::drop-down {border-style: none}" ) self.novelValue.refreshNovelList() self.tbNovel.setVisible(self.novelValue.count() > 1) return def clearContent(self) -> None: """Run clearing project tasks.""" self.novelValue.clear() self.novelValue.setToolTip("") return def buildNovelRootMenu(self) -> None: """Build the novel root menu.""" self.novelValue.refreshNovelList() self.tbNovel.setVisible(self.novelValue.count() > 1) return def setCurrentRoot(self, rootHandle: str | None) -> None: """Set the current active root handle.""" self.novelValue.setHandle(rootHandle) self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) return def setLastColType(self, colType: NovelTreeColumn, doRefresh: bool = True) -> None: """Set the last column type.""" self.aLastCol[colType].setChecked(True) self.novelView.novelTree.setLastColType(colType, doRefresh=doRefresh) return ## # Private Slots ## @pyqtSlot() def _refreshNovelTree(self) -> None: """Rebuild the current tree.""" rootHandle = SHARED.project.data.getLastHandle("novelTree") self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) return @pyqtSlot() def _selectLastColumnSize(self) -> None: """Set the maximum width for the last column.""" oldSize = self.novelView.novelTree.lastColSize newSize, isOk = QInputDialog.getInt( self, self.tr("Column Size"), self.tr("Maximum column size in %"), oldSize, 15, 75, 5 ) if isOk: self.novelView.novelTree.setLastColSize(newSize) self._refreshNovelTree() return ## # Internal Functions ## def _addLastColAction(self, colType: NovelTreeColumn, actionLabel: str) -> None: """Add a column selection entry to the last column menu.""" aLast = qtAddAction(self.mLastCol, actionLabel) aLast.setCheckable(True) aLast.setActionGroup(self.gLastCol) aLast.triggered.connect(qtLambda(self.setLastColType, colType)) self.aLastCol[colType] = aLast return class GuiNovelTree(QTreeWidget): C_DATA = 0 C_TITLE = 0 C_WORDS = 1 C_EXTRA = 2 C_MORE = 3 D_HANDLE = QtUserRole D_TITLE = QtUserRole + 1 D_KEY = QtUserRole + 2 D_EXTRA = QtUserRole + 3 def __init__(self, novelView: GuiNovelView) -> None: super().__init__(parent=novelView) logger.debug("Create: GuiNovelTree") self.novelView = novelView # Internal Variables 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 iSz = SHARED.theme.baseIconSize self.setIconSize(iSz) self.setFrameStyle(QFrame.Shape.NoFrame) self.setUniformRowHeights(True) self.setAllColumnsShowFocus(True) self.setHeaderHidden(True) self.setIndentation(0) 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 self.clicked.connect(self._treeItemClicked) self.itemDoubleClicked.connect(self._treeDoubleClick) self.itemSelectionChanged.connect(self._treeSelectionChange) # Set custom settings self.initSettings() self.updateTheme() logger.debug("Ready: GuiNovelTree") return 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)) return ## # Properties ## @property def lastColType(self) -> NovelTreeColumn: return self._lastCol @property def lastColSize(self) -> int: return int(self._lastColSize * 100) ## # Class Methods ## 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.""" logger.debug("Requesting refresh of the novel tree") if rootHandle is None: rootHandle = SHARED.project.tree.findRoot(nwItemClass.NOVEL) titleKey = None if selItems := self.selectedItems(): titleKey = selItems[0].data(self.C_DATA, self.D_KEY) self._populateTree(rootHandle) SHARED.project.data.setLastHandle(rootHandle, "novelTree") if titleKey is not None and titleKey in self._treeMap: self._treeMap[titleKey].setSelected(True) return def refreshHandle(self, tHandle: str) -> None: """Refresh the data for a given handle.""" if idxData := SHARED.project.index.getItemData(tHandle): logger.debug("Refreshing meta data for item '%s'", tHandle) for sTitle, tHeading in idxData.items(): sKey = f"{tHandle}:{sTitle}" if trItem := self._treeMap.get(sKey, None): self._updateTreeItemValues(trItem, tHeading, tHandle, sTitle) else: logger.debug("Heading '%s' not in novel tree", sKey) self.refreshTree() 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: logger.debug("Changing last column to %s", colType.name) self._lastCol = colType self.setColumnHidden(self.C_EXTRA, colType == NovelTreeColumn.HIDDEN) if doRefresh: lastNovel = SHARED.project.data.getLastHandle("novelTree") self.refreshTree(rootHandle=lastNovel, overRide=True) return def setLastColSize(self, colSize: int) -> None: """Set the column size in integer values between 15 and 75.""" self._lastColSize = minmax(colSize, 15, 75)/100.0 return def setActiveHandle(self, tHandle: str | None) -> None: """Highlight the rows associated with a given handle.""" didScroll = False brushOn = self.palette().alternateBase() brushOff = self.palette().base() if pHandle := self._actHandle: for key, item in self._treeMap.items(): if key.startswith(pHandle): for i in range(self.columnCount()): item.setBackground(i, brushOff) if tHandle: for key, item in self._treeMap.items(): if key.startswith(tHandle): for i in range(self.columnCount()): item.setBackground(i, brushOn) if not didScroll: self.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter) didScroll = True self._actHandle = tHandle or None return ## # Events ## def mousePressEvent(self, event: QMouseEvent) -> None: """Overload mousePressEvent to clear selection if clicking the mouse in a blank area of the tree view, and to load a document for viewing if the user middle-clicked. """ super().mousePressEvent(event) if event.button() == QtMouseLeft: selItem = self.indexAt(event.pos()) if not selItem.isValid(): self.clearSelection() elif event.button() == QtMouseMiddle: selItem = self.itemAt(event.pos()) if not isinstance(selItem, QTreeWidgetItem): return tHandle, sTitle = self.getSelectedHandle() if tHandle is None: return self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle or "", False) return def focusOutEvent(self, event: QFocusEvent) -> None: """Clear the selection when the tree no longer has focus.""" super().focusOutEvent(event) self.clearSelection() return def resizeEvent(self, event: QResizeEvent) -> None: """Elide labels in the extra column.""" super().resizeEvent(event) newW = event.size().width() oldW = event.oldSize().width() if newW != oldW: eliW = int(self._lastColSize * newW) fMetric = self.fontMetrics() for i in range(self.topLevelItemCount()): trItem = self.topLevelItem(i) if isinstance(trItem, QTreeWidgetItem): lastText = trItem.data(self.C_DATA, self.D_EXTRA) trItem.setText( self.C_EXTRA, fMetric.elidedText(lastText, Qt.TextElideMode.ElideRight, eliW) ) return ## # Private Slots ## @pyqtSlot("QModelIndex") def _treeItemClicked(self, index: QModelIndex) -> None: """The user clicked on an item in the tree.""" if index.column() == self.C_MORE: tHandle = index.siblingAtColumn(self.C_DATA).data(self.D_HANDLE) sTitle = index.siblingAtColumn(self.C_DATA).data(self.D_TITLE) tipPos = self.mapToGlobal(self.visualRect(index).topRight()) self._popMetaBox(tipPos, tHandle, sTitle) return @pyqtSlot() def _treeSelectionChange(self) -> None: """Extract the handle and line number of the currently selected title, and send it to the tree meta panel. """ tHandle, _ = self.getSelectedHandle() if tHandle is not None: self.novelView.selectedItemChanged.emit(tHandle) return @pyqtSlot("QTreeWidgetItem*", int) def _treeDoubleClick(self, item: QTreeWidgetItem, column: int) -> None: """Extract the handle and line number of the title double- clicked, and send it to the main gui class for opening in the document editor. """ tHandle, sTitle = self.getSelectedHandle() self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True) return ## # Internal Functions ## def _populateTree(self, rootHandle: str | None) -> None: """Build the tree based on the project index.""" self.clearContent() tStart = time() logger.debug("Building novel tree for root item '%s'", rootHandle) novStruct = SHARED.project.index.novelStructure(rootHandle=rootHandle, activeOnly=True) for tKey, tHandle, sTitle, novIdx in novStruct: if novIdx.level == "H0": continue newItem = QTreeWidgetItem() newItem.setData(self.C_DATA, self.D_HANDLE, tHandle) newItem.setData(self.C_DATA, self.D_TITLE, sTitle) newItem.setData(self.C_DATA, self.D_KEY, tKey) newItem.setTextAlignment(self.C_WORDS, QtAlignRight) self._updateTreeItemValues(newItem, novIdx, tHandle, sTitle) self._treeMap[tKey] = newItem self.addTopLevelItem(newItem) self.setActiveHandle(self._actHandle) logger.debug("Novel Tree built in %.3f ms", (time() - tStart)*1000) self._lastBuild = time() return def _updateTreeItemValues( self, trItem: QTreeWidgetItem, idxItem: IndexHeading, tHandle: str, sTitle: str ) -> None: """Set the tree item values from the index entry.""" iLevel = nwStyles.H_LEVEL.get(idxItem.level, 0) hDec = SHARED.theme.getHeaderDecoration(iLevel) trItem.setData(self.C_TITLE, QtDecoration, hDec) trItem.setText(self.C_TITLE, idxItem.title) trItem.setFont(self.C_TITLE, self._hFonts[iLevel]) trItem.setText(self.C_WORDS, f"{idxItem.wordCount:n}") trItem.setData(self.C_MORE, QtDecoration, self._pMore) # Custom column viewport = self.viewport() mW = int(self._lastColSize * (viewport.width() if viewport else 100)) lastText, toolTip = self._getLastColumnText(tHandle, sTitle) elideText = self.fontMetrics().elidedText(lastText, Qt.TextElideMode.ElideRight, mW) trItem.setText(self.C_EXTRA, elideText) trItem.setData(self.C_DATA, self.D_EXTRA, lastText) trItem.setToolTip(self.C_EXTRA, toolTip) return def _getLastColumnText(self, tHandle: str, sTitle: str) -> tuple[str, str]: """Generate text for the last column based on user settings.""" if self._lastCol == NovelTreeColumn.HIDDEN: return "", "" refData = [] refName = "" refs = SHARED.project.index.getReferences(tHandle, sTitle) if self._lastCol == NovelTreeColumn.POV: refData = refs[nwKeyWords.POV_KEY] refName = self._povLabel elif self._lastCol == NovelTreeColumn.FOCUS: refData = refs[nwKeyWords.FOCUS_KEY] refName = self._focLabel elif self._lastCol == NovelTreeColumn.PLOT: refData = refs[nwKeyWords.PLOT_KEY] refName = self._pltLabel if refData: toolText = ", ".join(refData) return toolText, f"{refName}: {toolText}" return "", "" def _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None: """Show the novel meta data box.""" logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle) pIndex = SHARED.project.index novIdx = pIndex.getItemHeading(tHandle, sTitle) refTags = pIndex.getReferences(tHandle, sTitle) if not novIdx: return synopText = novIdx.synopsis if synopText: synopLabel = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) synopText = f"

{synopLabel}: {synopText}

" refLines = [] refLines = self._appendMetaTag(refTags, nwKeyWords.POV_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.FOCUS_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.CHAR_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.PLOT_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.TIME_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.WORLD_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.OBJECT_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.ENTITY_KEY, refLines) refLines = self._appendMetaTag(refTags, nwKeyWords.CUSTOM_KEY, refLines) refText = "" if refLines: refList = "
".join(refLines) refText = f"

{refList}

" ttText = refText + synopText or self.tr("No meta data") if ttText: QToolTip.showText(qPos, ttText) return @staticmethod def _appendMetaTag(refs: dict, key: str, lines: list[str]) -> list[str]: """Generate a reference list for a given reference key.""" tags = ", ".join(refs.get(key, [])) if tags: lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") return lines