""" novelWriter – GUI Novel Tree ============================ GUI classe for the main window novel tree File History: Created: 2020-12-20 [1.1a0] 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 time import time from enum import Enum from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal from PyQt5.QtGui import QPalette from PyQt5.QtWidgets import ( QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QHeaderView, QLabel, QMenu, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter.enum import nwDocMode, nwItemClass from novelwriter.common import checkInt from novelwriter.constants import nwKeyWords, nwLabels logger = logging.getLogger(__name__) class GuiNovelView(QWidget): # Signals for user interaction with the novel tree selectedItemChanged = pyqtSignal(str) openDocumentRequest = pyqtSignal(str, Enum, int, str) def __init__(self, mainGui): QWidget.__init__(self, mainGui) self.mainGui = mainGui self.theProject = mainGui.theProject # Build GUI self.novelTree = GuiNovelTree(self) self.novelBar = GuiNovelToolBar(self) # 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) # Connect Signals self.novelBar.rootFolderSelectionChanged.connect( lambda tHandle: self.novelTree.refreshTree(rootHandle=tHandle, overRide=True) ) # Function Mappings self.refreshTree = self.novelTree.refreshTree self.updateWordCounts = self.novelTree.updateWordCounts self.getSelectedHandle = self.novelTree.getSelectedHandle return ## # Methods ## def initSettings(self): self.novelTree.initSettings() return def clearProject(self): self.novelTree.clearTree() return def openProjectTasks(self): """Run tasks related to opening a project. """ lastNovel = self.theProject.lastNovel if lastNovel is None: lastNovel = self.theProject.tree.findRoot(nwItemClass.NOVEL) logger.debug("Setting novel tree to root item '%s'", lastNovel) self.clearProject() self.novelBar.rebuildNovelRootMenu(selHandle=lastNovel) self.novelTree.refreshTree(rootHandle=lastNovel, overRide=True) return def setFocus(self): """Forward the set focus call to the tree widget. """ self.novelTree.setFocus() return def treeFocus(self): """Check if the novel tree has focus. """ return self.novelTree.hasFocus() ## # Public Slots ## @pyqtSlot(str) def updateRootItem(self, tHandle): """Should be called whenever a root folders changes. """ self.novelBar.rebuildNovelRootMenu() return # END Class GuiNovelView class GuiNovelToolBar(QWidget): rootFolderSelectionChanged = pyqtSignal(str) def __init__(self, novelView): QTreeWidget.__init__(self, 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(4) self.setContentsMargins(0, 0, 0, 0) self.setAutoFillBackground(True) qPalette = self.palette() qPalette.setBrush(QPalette.Window, qPalette.base()) self.setPalette(qPalette) 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(mPx, fadeCol.red(), fadeCol.green(), fadeCol.blue()) # Widget Label self.viewLabel = QLabel("%s" % self.tr("Novel Outline")) self.viewLabel.setContentsMargins(0, 0, 0, 0) self.viewLabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Novel Root Menu self.mRoot = QMenu() self.tbRoot = QToolButton(self) self.tbRoot.setToolTip(self.tr("Novel Root")) self.tbRoot.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL])) self.tbRoot.setIconSize(QSize(iPx, iPx)) self.tbRoot.setStyleSheet(buttonStyle) self.tbRoot.setMenu(self.mRoot) self.tbRoot.setPopupMode(QToolButton.InstantPopup) # More Options Menu self.mMore = QMenu() self.tbMore = QToolButton(self) self.tbMore.setToolTip(self.tr("More Options")) self.tbMore.setIcon(self.mainTheme.getIcon("menu")) self.tbMore.setIconSize(QSize(iPx, iPx)) self.tbMore.setStyleSheet(buttonStyle) self.tbMore.setMenu(self.mMore) self.tbMore.setPopupMode(QToolButton.InstantPopup) # Assemble self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.viewLabel) 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) logger.debug("GuiNovelToolBar initialisation complete") return ## # Methods ## def rebuildNovelRootMenu(self, selHandle=None): """Build the novel root menu. """ self.mRoot.clear() agRoot = QActionGroup(self.mRoot) 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.rootFolderSelectionChanged.emit(tHandle) ) agRoot.addAction(aRoot) if n == 0: aRoot.setChecked(True) if selHandle == tHandle: aRoot.setChecked(True) return # END Class GuiNovelToolBar class GuiNovelTree(QTreeWidget): C_TITLE = 0 C_WORDS = 1 C_POV = 2 def __init__(self, novelView): QTreeWidget.__init__(self, 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 # Build GUI # ========= iPx = self.mainTheme.baseIconSize cMg = self.mainConf.pxInt(6) self.setIconSize(QSize(iPx, iPx)) self.setFrameStyle(QFrame.NoFrame) self.setHeaderHidden(True) self.setIndentation(iPx) self.setColumnCount(3) 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_POV, QHeaderView.ResizeToContents) # Connect signals self.itemDoubleClicked.connect(self._treeDoubleClick) self.itemSelectionChanged.connect(self._treeSelectionChange) # Set custom settings self.initSettings() 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 ## # Class Methods ## def clearTree(self): """Clear the GUI content and the related maps. """ self.clear() self._treeMap = {} self._lastBuild = 0 return def refreshTree(self, rootHandle=None, overRide=False): """Called whenever the Novel tab is activated. """ logger.verbose("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.verbose("No changes have been made to the novel index") return selItem = self.selectedItems() titleKey = None if selItem: titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2] self._populateTree(rootHandle) self.theProject.setLastNovelViewed(rootHandle) if titleKey is not None and titleKey in self._treeMap: self._treeMap[titleKey].setSelected(True) return def updateWordCounts(self, tHandle): """Update the word count for a given handle. """ tHeaders = self.theProject.index.getHandleWordCounts(tHandle) for titleKey, wCount in tHeaders: if titleKey in self._treeMap: self._treeMap[titleKey].setText(self.C_WORDS, f"{wCount:n}") return def getSelectedHandle(self): """Get the currently selected handle. If multiple items are selected, return the first. """ selItem = self.selectedItems() tHandle = None tLine = 0 if selItem: tHandle = selItem[0].data(self.C_TITLE, Qt.UserRole)[0] tLine = checkInt(selItem[0].data(self.C_TITLE, Qt.UserRole)[1], 1) - 1 return tHandle, tLine ## # 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. """ QTreeWidget.mousePressEvent(self, 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, _ = self.getSelectedHandle() if tHandle is None: return self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, -1, "") return ## # Private Slots ## @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, tLine = self.getSelectedHandle() self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, tLine, "") return ## # Internal Functions ## def _populateTree(self, rootHandle): """Build the tree based on the project index. """ self.clearTree() currTitle = None currChapter = None currScene = None logger.verbose("Building novel tree for root item '%s'", rootHandle) novStruct = self.theProject.index.novelStructure(rootHandle=rootHandle, skipExcl=True) for tKey, tHandle, sTitle, novIdx in novStruct: tItem = self._createTreeItem(tHandle, sTitle, tKey, novIdx) self._treeMap[tKey] = tItem tLevel = novIdx.level if tLevel == "H1": self.addTopLevelItem(tItem) currTitle = tItem currChapter = None currScene = None elif tLevel == "H2": if currTitle is None: self.addTopLevelItem(tItem) else: currTitle.addChild(tItem) currChapter = tItem currScene = None elif tLevel == "H3": if currChapter is None: if currTitle is None: self.addTopLevelItem(tItem) else: currTitle.addChild(tItem) else: currChapter.addChild(tItem) currScene = tItem elif tLevel == "H4": if currScene is None: if currChapter is None: if currTitle is None: self.addTopLevelItem(tItem) else: currTitle.addChild(tItem) else: currChapter.addChild(tItem) else: currScene.addChild(tItem) tItem.setExpanded(True) self._lastBuild = time() return def _createTreeItem(self, tHandle, sTitle, titleKey, novIdx): """Populate a tree item with all the column values. """ newItem = QTreeWidgetItem() hIcon = "doc_%s" % novIdx.level.lower() theData = (tHandle, sTitle[1:].lstrip("0"), titleKey) wC = int(novIdx.wordCount) newItem.setText(self.C_TITLE, novIdx.title) newItem.setData(self.C_TITLE, Qt.UserRole, theData) newItem.setIcon(self.C_TITLE, self.mainTheme.getIcon(hIcon)) newItem.setText(self.C_WORDS, f"{wC:n}") newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) theRefs = self.theProject.index.getReferences(tHandle, sTitle) newItem.setText(self.C_POV, ", ".join(theRefs[nwKeyWords.POV_KEY])) return newItem # END Class GuiNovelTree