""" novelWriter – GUI Novel Tree ============================ GUI classe for the main window novel tree File History: Created: 2020-12-20 [1.1a0] GuiNovelTree Created: 2022-06-12 [1.7b1] GuiNovelView Created: 2022-06-12 [1.7b1] GuiNovelToolBar This file is a part of novelWriter Copyright 2018–2020, Veronica Berglyd Olsen 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 . """ import logging import novelwriter from enum import Enum from time import time from PyQt5.QtGui import QPalette from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal from PyQt5.QtWidgets import ( QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QHeaderView, QLabel, QMenu, QSizePolicy, QToolButton, QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter.enum import nwDocMode, nwItemClass, nwOutline from novelwriter.constants import nwHeaders, nwKeyWords, nwLabels, trConst logger = logging.getLogger(__name__) class NovelTreeColumn(Enum): HIDDEN = 0 POV = 1 FOCUS = 2 PLOT = 3 # END Enum NovelTreeColumn class GuiNovelView(QWidget): # Signals for user interaction with the novel tree selectedItemChanged = pyqtSignal(str) openDocumentRequest = pyqtSignal(str, Enum, str, bool) def __init__(self, mainGui): super().__init__(parent=mainGui) self.mainGui = mainGui self.theProject = mainGui.theProject # 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 self.setActiveHandle = self.novelTree.setActiveHandle return ## # Methods ## def updateTheme(self): """Update theme elements. """ self.novelBar.updateTheme() self.novelTree.updateTheme() self.refreshTree() return def initSettings(self): """Initialise GUI elements that depend on specific settings. """ self.novelTree.initSettings() return def clearProject(self): """Clear project-related GUI content. """ self.novelTree.clearContent() self.novelBar.clearContent() self.novelBar.setEnabled(False) return def openProjectTasks(self): """Run open project tasks. """ lastNovel = self.theProject.data.getLastHandle("novelTree") if lastNovel not in self.theProject.tree: lastNovel = self.theProject.tree.findRoot(nwItemClass.NOVEL) logger.debug("Setting novel tree to root item '%s'", lastNovel) lastCol = self.theProject.options.getEnum( "GuiNovelView", "lastCol", NovelTreeColumn, NovelTreeColumn.HIDDEN ) self.clearProject() self.novelBar.buildNovelRootMenu() self.novelBar.setLastColType(lastCol, doRefresh=False) self.novelBar.setCurrentRoot(lastNovel) self.novelBar.setEnabled(True) return def closeProjectTasks(self): """Run closing project tasks. """ lastColType = self.novelTree.lastColType self.theProject.options.setValue("GuiNovelView", "lastCol", lastColType) return def setTreeFocus(self): """Set the focus to the tree widget. """ self.novelTree.setFocus() return def treeHasFocus(self): """Check if the novel tree has focus. """ return self.novelTree.hasFocus() ## # Public Slots ## @pyqtSlot() def refreshTree(self): """Refresh the current tree. """ self.novelTree.refreshTree(rootHandle=self.theProject.data.getLastHandle("novelTree")) return @pyqtSlot(str) def updateRootItem(self, tHandle): """If any root item changes, rebuild the novel root menu. """ self.novelBar.buildNovelRootMenu() return @pyqtSlot(str) def updateNovelItemMeta(self, tHandle): """The meta data of a novel item has changed, and the tree item needs to be refreshed. """ self.novelTree.refreshHandle(tHandle) return # END Class GuiNovelView class GuiNovelToolBar(QWidget): def __init__(self, novelView): super().__init__(parent=novelView) logger.debug("Initialising GuiNovelToolBar ...") self.mainConf = novelwriter.CONFIG self.novelView = novelView self.theProject = novelView.mainGui.theProject self.mainTheme = novelView.mainGui.mainTheme iPx = self.mainTheme.baseIconSize mPx = self.mainConf.pxInt(2) self.setContentsMargins(0, 0, 0, 0) self.setAutoFillBackground(True) # Widget Label self.viewLabel = QLabel("%s" % self.tr("Novel Outline")) self.viewLabel.setContentsMargins(0, 0, 0, 0) self.viewLabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Refresh Button self.tbRefresh = QToolButton(self) self.tbRefresh.setToolTip(self.tr("Refresh")) self.tbRefresh.setIconSize(QSize(iPx, iPx)) self.tbRefresh.clicked.connect(self._refreshNovelTree) # Novel Root Menu self.mRoot = QMenu() self.gRoot = QActionGroup(self.mRoot) self.aRoot = {} self.tbRoot = QToolButton(self) self.tbRoot.setToolTip(self.tr("Novel Root")) self.tbRoot.setIconSize(QSize(iPx, iPx)) self.tbRoot.setMenu(self.mRoot) self.tbRoot.setPopupMode(QToolButton.InstantPopup) # More Options Menu self.mMore = QMenu() self.mLastCol = self.mMore.addMenu(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.tbMore = QToolButton(self) self.tbMore.setToolTip(self.tr("More Options")) self.tbMore.setIconSize(QSize(iPx, iPx)) self.tbMore.setMenu(self.mMore) self.tbMore.setPopupMode(QToolButton.InstantPopup) # Assemble self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.viewLabel) self.outerBox.addWidget(self.tbRefresh) self.outerBox.addWidget(self.tbRoot) self.outerBox.addWidget(self.tbMore) self.outerBox.setContentsMargins(mPx, mPx, 0, mPx) self.outerBox.setSpacing(0) self.setLayout(self.outerBox) self.updateTheme() logger.debug("GuiNovelToolBar initialisation complete") return ## # Methods ## def updateTheme(self): """Update theme elements. """ # Icons self.tbRefresh.setIcon(self.mainTheme.getIcon("refresh")) self.tbRoot.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL])) self.tbMore.setIcon(self.mainTheme.getIcon("menu")) qPalette = self.palette() qPalette.setBrush(QPalette.Window, qPalette.base()) self.setPalette(qPalette) # StyleSheets fadeCol = qPalette.text().color() buttonStyle = ( "QToolButton {{padding: {0}px; border: none; background: transparent;}} " "QToolButton:hover {{border: none; background: rgba({1},{2},{3},0.2);}}" ).format(self.mainConf.pxInt(2), fadeCol.red(), fadeCol.green(), fadeCol.blue()) self.tbRefresh.setStyleSheet(buttonStyle) self.tbRoot.setStyleSheet(buttonStyle) self.tbMore.setStyleSheet(buttonStyle) return def clearContent(self): """Run clearing project tasks. """ self.mRoot.clear() self.aRoot = {} return def buildNovelRootMenu(self): """Build the novel root menu. """ self.mRoot.clear() self.aRoot = {} for n, (tHandle, nwItem) in enumerate(self.theProject.tree.iterRoots(nwItemClass.NOVEL)): aRoot = self.mRoot.addAction(nwItem.itemName) aRoot.setData(tHandle) aRoot.setCheckable(True) aRoot.triggered.connect(lambda n, tHandle=tHandle: self.setCurrentRoot(tHandle)) self.gRoot.addAction(aRoot) self.aRoot[tHandle] = aRoot return def setCurrentRoot(self, rootHandle): """Set the current active root handle. """ if rootHandle in self.aRoot: self.aRoot[rootHandle].setChecked(True) self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) return def setLastColType(self, colType, doRefresh=True): """Set the last column type. """ self.aLastCol[colType].setChecked(True) self.novelView.novelTree.setLastColType(colType, doRefresh=doRefresh) return ## # Private Slots ## @pyqtSlot() def _refreshNovelTree(self): """Rebuild the current tree. """ rootHandle = self.theProject.data.getLastHandle("novelTree") self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) return ## # Internal Functions ## def _addLastColAction(self, colType, actionLabel): """Add a column selection entry to the last column menu. """ aLast = self.mLastCol.addAction(actionLabel) aLast.setCheckable(True) aLast.setActionGroup(self.gLastCol) aLast.triggered.connect(lambda: self.setLastColType(colType)) self.aLastCol[colType] = aLast return # END Class GuiNovelToolBar class GuiNovelTree(QTreeWidget): C_TITLE = 0 C_WORDS = 1 C_EXTRA = 2 C_MORE = 3 D_HANDLE = Qt.UserRole D_TITLE = Qt.UserRole + 1 D_KEY = Qt.UserRole + 2 def __init__(self, novelView): super().__init__(parent=novelView) logger.debug("Initialising GuiNovelTree ...") self.mainConf = novelwriter.CONFIG self.novelView = novelView self.mainGui = novelView.mainGui self.mainTheme = novelView.mainGui.mainTheme self.theProject = novelView.mainGui.theProject # Internal Variables self._treeMap = {} self._lastBuild = 0 self._lastCol = NovelTreeColumn.POV self._actHandle = None # 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 = self.mainTheme.baseIconSize cMg = self.mainConf.pxInt(6) self.setIconSize(QSize(iPx, iPx)) self.setFrameStyle(QFrame.NoFrame) self.setUniformRowHeights(True) self.setAllColumnsShowFocus(True) self.setHeaderHidden(True) self.setIndentation(0) self.setColumnCount(4) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setExpandsOnDoubleClick(False) self.setDragEnabled(False) # Lock the column sizes treeHeader = self.header() treeHeader.setStretchLastSection(False) treeHeader.setMinimumSectionSize(iPx + cMg) treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.Stretch) treeHeader.setSectionResizeMode(self.C_WORDS, QHeaderView.ResizeToContents) treeHeader.setSectionResizeMode(self.C_EXTRA, QHeaderView.ResizeToContents) treeHeader.setSectionResizeMode(self.C_MORE, QHeaderView.ResizeToContents) # 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("GuiNovelTree initialisation complete") return def initSettings(self): """Set or update tree widget settings. """ # Scroll bars if self.mainConf.hideVScroll: self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) else: self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) if self.mainConf.hideHScroll: self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) else: self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) return def updateTheme(self): """Update theme elements. """ iPx = self.mainTheme.baseIconSize self._pMore = self.mainTheme.loadDecoration("deco_doc_more", pxH=iPx) return ## # Properties ## @property def lastColType(self): return self._lastCol ## # Class Methods ## def clearContent(self): """Clear the GUI content and the related maps. """ self.clear() self._treeMap = {} self._lastBuild = 0 return def refreshTree(self, rootHandle=None, overRide=False): """Refresh the tree if it has been changed. """ logger.debug("Requesting refresh of the novel tree") if rootHandle is None: rootHandle = self.theProject.tree.findRoot(nwItemClass.NOVEL) treeChanged = self.mainGui.projView.changedSince(self._lastBuild) indexChanged = self.theProject.index.rootChangedSince(rootHandle, self._lastBuild) if not (treeChanged or indexChanged or overRide): logger.debug("No changes have been made to the novel index") return selItem = self.selectedItems() titleKey = None if selItem: titleKey = selItem[0].data(self.C_TITLE, self.D_KEY) self._populateTree(rootHandle) self.theProject.data.setLastHandle(rootHandle, "novelTree") if titleKey is not None and titleKey in self._treeMap: self._treeMap[titleKey].setSelected(True) return def refreshHandle(self, tHandle): """Refresh the data for a given handle. """ idxData = self.theProject.index.getItemData(tHandle) if idxData is None: return logger.debug("Refreshing meta data for item '%s'", tHandle) for sTitle, tHeading in idxData.items(): sKey = f"{tHandle}:{sTitle}" trItem = self._treeMap.get(sKey, None) if trItem is None: logger.debug("Heading '%s' not in novel tree", sKey) self.refreshTree() return self._updateTreeItemValues(trItem, tHeading, tHandle, sTitle) return def getSelectedHandle(self): """Get the currently selected handle. If multiple items are selected, return the first. """ selItem = self.selectedItems() if selItem: tHandle = selItem[0].data(self.C_TITLE, self.D_HANDLE) sTitle = selItem[0].data(self.C_TITLE, self.D_TITLE) return tHandle, sTitle return None, None def setLastColType(self, colType, doRefresh=True): """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 = self.theProject.data.getLastHandle("novelTree") self.refreshTree(rootHandle=lastNovel, overRide=True) return def setActiveHandle(self, tHandle): """Highlight the rows associated with a given handle. """ tStart = time() self._actHandle = tHandle for i in range(self.topLevelItemCount()): tItem = self.topLevelItem(i) if tItem is not None: if tItem.data(self.C_TITLE, self.D_HANDLE) == tHandle: tItem.setBackground(self.C_TITLE, self.palette().alternateBase()) tItem.setBackground(self.C_WORDS, self.palette().alternateBase()) tItem.setBackground(self.C_EXTRA, self.palette().alternateBase()) tItem.setBackground(self.C_MORE, self.palette().alternateBase()) else: tItem.setBackground(self.C_TITLE, self.palette().base()) tItem.setBackground(self.C_WORDS, self.palette().base()) tItem.setBackground(self.C_EXTRA, self.palette().base()) tItem.setBackground(self.C_MORE, self.palette().base()) logger.debug("Highlighted Novel Tree in %.3f ms", (time() - tStart)*1000) return ## # Events ## def mousePressEvent(self, theEvent): """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(theEvent) if theEvent.button() == Qt.LeftButton: selItem = self.indexAt(theEvent.pos()) if not selItem.isValid(): self.clearSelection() elif theEvent.button() == Qt.MiddleButton: selItem = self.itemAt(theEvent.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, theEvent): """Clear the selection when the tree no longer has focus. """ super().focusOutEvent(theEvent) self.clearSelection() return ## # Private Slots ## @pyqtSlot("QModelIndex") def _treeItemClicked(self, mIndex): """The user clicked on an item in the tree. """ if mIndex.column() == self.C_MORE: tHandle = mIndex.siblingAtColumn(self.C_TITLE).data(self.D_HANDLE) sTitle = mIndex.siblingAtColumn(self.C_TITLE).data(self.D_TITLE) tipPos = self.mapToGlobal(self.visualRect(mIndex).topRight()) self._popMetaBox(tipPos, tHandle, sTitle) return @pyqtSlot() def _treeSelectionChange(self): """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, tItem, colNo): """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): """Build the tree based on the project index. """ self.clearContent() tStart = time() logger.debug("Building novel tree for root item '%s'", rootHandle) novStruct = self.theProject.index.novelStructure(rootHandle=rootHandle, skipExcl=True) for tKey, tHandle, sTitle, novIdx in novStruct: if novIdx.level == "H0": continue newItem = QTreeWidgetItem() newItem.setData(self.C_TITLE, self.D_HANDLE, tHandle) newItem.setData(self.C_TITLE, self.D_TITLE, sTitle) newItem.setData(self.C_TITLE, self.D_KEY, tKey) newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) 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, idxItem, tHandle, sTitle): """Set the tree item values from the index entry. """ iLevel = nwHeaders.H_LEVEL.get(idxItem.level, 0) hDec = self.mainTheme.getHeaderDecoration(iLevel) trItem.setData(self.C_TITLE, Qt.DecorationRole, 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, Qt.DecorationRole, self._pMore) # Custom column lastText, toolTip = self._getLastColumnText(tHandle, sTitle) trItem.setText(self.C_EXTRA, lastText) if lastText: trItem.setToolTip(self.C_EXTRA, toolTip) return def _getLastColumnText(self, tHandle, sTitle): """Generate the text for the last column based on user settings. """ if self._lastCol == NovelTreeColumn.HIDDEN: return "", "" theRefs = self.theProject.index.getReferences(tHandle, sTitle) if self._lastCol == NovelTreeColumn.POV: newText = ", ".join(theRefs[nwKeyWords.POV_KEY]) return newText, f"{self._povLabel}: {newText}" elif self._lastCol == NovelTreeColumn.FOCUS: newText = ", ".join(theRefs[nwKeyWords.FOCUS_KEY]) return newText, f"{self._focLabel}: {newText}" elif self._lastCol == NovelTreeColumn.PLOT: newText = ", ".join(theRefs[nwKeyWords.PLOT_KEY]) return newText, f"{self._pltLabel}: {newText}" return "", "" def _popMetaBox(self, qPos, tHandle, sTitle): """Show the novel meta data box. """ logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle) pIndex = self.theProject.index novIdx = pIndex.getItemHeader(tHandle, sTitle) refTags = pIndex.getReferences(tHandle, sTitle) 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, key, lines): """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 # END Class GuiNovelTree