# -*- coding: utf-8 -*- """novelWriter GUI Project Outline novelWriter – GUI Project Outline =================================== Class holding the project outline view File History: Created: 2019-11-16 [0.4.1] 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 nw import logging from time import time from PyQt5.QtCore import Qt, QSize from PyQt5.QtWidgets import ( QTreeWidget, QTreeWidgetItem, QMenu, QAction, QAbstractItemView ) from nw.constants import nwKeyWords, nwLabels, nwOutline logger = logging.getLogger(__name__) class GuiOutline(QTreeWidget): DEF_WIDTH = { nwOutline.TITLE : 200, nwOutline.LEVEL : 40, nwOutline.LABEL : 150, nwOutline.LINE : 40, nwOutline.CCOUNT : 50, nwOutline.WCOUNT : 50, nwOutline.PCOUNT : 50, nwOutline.POV : 100, nwOutline.CHAR : 100, nwOutline.PLOT : 100, nwOutline.TIME : 100, nwOutline.WORLD : 100, nwOutline.OBJECT : 100, nwOutline.ENTITY : 100, nwOutline.CUSTOM : 100, nwOutline.SYNOP : 200, } DEF_HIDDEN = { nwOutline.TITLE : False, nwOutline.LEVEL : True, nwOutline.LABEL : False, nwOutline.LINE : True, nwOutline.CCOUNT : True, nwOutline.WCOUNT : False, nwOutline.PCOUNT : False, nwOutline.POV : False, nwOutline.CHAR : False, nwOutline.PLOT : False, nwOutline.TIME : True, nwOutline.WORLD : False, nwOutline.OBJECT : True, nwOutline.ENTITY : True, nwOutline.CUSTOM : True, nwOutline.SYNOP : False, } def __init__(self, theParent): QTreeWidget.__init__(self, theParent) logger.debug("Initialising GuiOutline ...") self.mainConf = nw.CONFIG self.theParent = theParent self.theProject = theParent.theProject self.theTheme = theParent.theTheme self.theIndex = theParent.theIndex self.optState = theParent.theProject.optState self.headerMenu = GuiOutlineHeaderMenu(self) self.firstView = True self.lastBuild = 0 self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setExpandsOnDoubleClick(False) self.setDragEnabled(False) self.itemDoubleClicked.connect(self._treeDoubleClick) self.itemSelectionChanged.connect(self._itemSelected) iPx = self.theTheme.baseIconSize self.setIconSize(QSize(iPx, iPx)) self.setIndentation(iPx) self.treeHead = self.header() self.treeHead.setContextMenuPolicy(Qt.CustomContextMenu) self.treeHead.customContextMenuRequested.connect(self._headerRightClick) self.treeHead.sectionMoved.connect(self._columnMoved) self.treeMap = {} self.treeOrder = [] self.colWidth = {} self.colHidden = {} self.colIndex = {} self.treeNCols = 0 self.clearOutline() self.headerMenu.setHiddenState(self.colHidden) logger.debug("GuiOutline initialisation complete") return def clearOutline(self): """Clear the tree and header and set the default values for the columns arrays. """ self.clear() self.setColumnCount(1) self.setHeaderLabel(nwLabels.OUTLINE_COLS[nwOutline.TITLE]) self.treeOrder = [] self.colWidth = {} self.colHidden = {} self.colIndex = {} self.treeNCols = 0 for i, hItem in enumerate(nwOutline): self.treeOrder.append(hItem) self.colWidth[hItem] = self.DEF_WIDTH[hItem] self.colHidden[hItem] = self.DEF_HIDDEN[hItem] self.treeNCols = len(self.treeOrder) return def refreshTree(self, overRide=False): """Called whenever the Outline tab is activated and controls what data to load, and if necessary, force a rebuild of the tree. """ # If it's the first time, we always build if self.firstView or self.firstView and overRide: self._loadHeaderState() self._populateTree() self.firstView = False return # If the novel index has changed since the tree was last built, # we rebuild the tree from the updated index. lastChange = self.theParent.theIndex.timeNovel logger.verbose("Last outline build: %.3f" % self.lastBuild) logger.verbose("Novel index change: %.3f" % lastChange) doBuild = lastChange > self.lastBuild and self.theProject.autoOutline if doBuild or overRide: logger.debug("Rebuilding Project Outline") self._populateTree() return def closeOutline(self): """Called before a project is closed. """ self._saveHeaderState() self.clearOutline() self.firstView = True return ## # Slots ## def _treeDoubleClick(self, tItem, tCol): """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 = tItem.data(self.colIndex[nwOutline.TITLE], Qt.UserRole) try: tLine = int(tItem.text(self.colIndex[nwOutline.LINE])) except Exception: tLine = 1 logger.verbose("User selected entry with handle %s on line %s" % (tHandle, tLine)) self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True) return def _itemSelected(self): """Extract the handle and line number of the currently selected title, and send it to the details panel. """ selItems = self.selectedItems() if selItems: tHandle = selItems[0].data(self.colIndex[nwOutline.TITLE], Qt.UserRole) sTitle = selItems[0].data(self.colIndex[nwOutline.LINE], Qt.UserRole) self.theParent.projMeta.showItem(tHandle, sTitle) return def _headerRightClick(self, clickPos): """Show the header column menu. """ self.headerMenu.exec_(self.mapToGlobal(clickPos)) return def _columnMoved(self, logIdx, oldVisualIdx, newVisualIdx): """Make sure the order array is up to date with the actual order of the columns. """ self.treeOrder.insert(newVisualIdx, self.treeOrder.pop(oldVisualIdx)) self._saveHeaderState() return def _menuColumnToggled(self, isChecked, theItem): """Receive the changes to column visibility forwarded by the header context menu. """ logger.verbose("User toggled Outline column '%s'" % theItem.name) if theItem in self.colIndex: self.setColumnHidden(self.colIndex[theItem], not isChecked) self._saveHeaderState() return ## # Internal Functions ## def _loadHeaderState(self): """Load the state of the main tree header, that is, column order and column width. """ # Load whatever we saved last time, regardless of wether it # contains the correct names or number of columns. The names # must be valid though. tempOrder = self.optState.getValue("GuiOutline", "headerOrder", []) treeOrder = [] for hName in tempOrder: try: treeOrder.append(nwOutline[hName]) except Exception: logger.warning("Ignored unknown outline column '%s'" % str(hName)) # Add columns that was not in the file to the treeOrder array. for hItem in nwOutline: if hItem not in treeOrder: treeOrder.append(hItem) # Check that we now have a complete list, and only if so, save # the order loaded from file. Otherwise, we keep the default. if len(treeOrder) == self.treeNCols: self.treeOrder = treeOrder else: logger.error("Failed to extract outline column order from previous session") logger.error("Column count doesn't match %d != %d" % (len(treeOrder), self.treeNCols)) # We load whatever column widths and hidden states we find in # the file, and leave the rest in their default state. tmpWidth = self.optState.getValue("GuiOutline", "columnWidth", {}) for hName in tmpWidth: try: self.colWidth[nwOutline[hName]] = self.mainConf.pxInt(tmpWidth[hName]) except Exception: logger.warning("Ignored unknown outline column '%s'" % str(hName)) tmpHidden = self.optState.getValue("GuiOutline", "columnHidden", {}) for hName in tmpHidden: try: self.colHidden[nwOutline[hName]] = tmpHidden[hName] except Exception: logger.warning("Ignored unknown outline column '%s'" % str(hName)) self.headerMenu.setHiddenState(self.colHidden) return def _saveHeaderState(self): """Save the state of the main tree header, that is, column order, column width and column hidden state. We don't want to save the current width of hidden columns though. This preserves the last known width in case they're unhidden again. """ # If we haven't built the tree, there is nothing to save. if self.lastBuild == 0: return treeOrder = [] colWidth = {} colHidden = {} for hItem in nwOutline: colWidth[hItem.name] = self.mainConf.rpxInt(self.colWidth[hItem]) colHidden[hItem.name] = self.colHidden[hItem] for iCol in range(self.columnCount()): hName = self.treeOrder[iCol].name treeOrder.append(hName) iLog = self.treeHead.logicalIndex(iCol) logWidth = self.mainConf.rpxInt(self.columnWidth(iLog)) logHidden = self.isColumnHidden(iLog) colHidden[hName] = logHidden if not logHidden and logWidth > 0: colWidth[hName] = logWidth self.optState.setValue("GuiOutline", "headerOrder", treeOrder) self.optState.setValue("GuiOutline", "columnWidth", colWidth) self.optState.setValue("GuiOutline", "columnHidden", colHidden) self.optState.saveSettings() return def _populateTree(self): """Build the tree based on the project index, and the header based on the defined constants, default values and user selected width, order and hidden state. All columns are populated, even if they are hidden. This ensures that showing and hiding columns is fast and doesn't require a rebuild of the tree. """ self.clear() if self.firstView: theLabels = [] for i, hItem in enumerate(self.treeOrder): theLabels.append(nwLabels.OUTLINE_COLS[hItem]) self.colIndex[hItem] = i self.setHeaderLabels(theLabels) for hItem in self.treeOrder: self.setColumnWidth(self.colIndex[hItem], self.colWidth[hItem]) self.setColumnHidden(self.colIndex[hItem], self.colHidden[hItem]) # Make sure title column is always visible, # and handle column always hidden self.setColumnHidden(self.colIndex[nwOutline.TITLE], False) headItem = self.headerItem() headItem.setTextAlignment(self.colIndex[nwOutline.CCOUNT], Qt.AlignRight) headItem.setTextAlignment(self.colIndex[nwOutline.WCOUNT], Qt.AlignRight) headItem.setTextAlignment(self.colIndex[nwOutline.PCOUNT], Qt.AlignRight) currTitle = None currChapter = None currScene = None for titleKey in self.theIndex.getNovelStructure(skipExcluded=True): if len(titleKey) < 16: continue tHandle = titleKey[:13] sTitle = titleKey[14:] if tHandle not in self.theIndex.novelIndex: continue if sTitle not in self.theIndex.novelIndex[tHandle]: continue tLevel = self.theIndex.novelIndex[tHandle][sTitle]["level"] tItem = self._createTreeItem(tHandle, sTitle, tLevel) self.treeMap[titleKey] = tItem if tLevel == "H1": currTitle = tItem self.addTopLevelItem(tItem) elif tLevel == "H2": if currTitle is None: self.addTopLevelItem(tItem) else: currTitle.addChild(tItem) currChapter = tItem 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, tLevel): """Populate a tree item with all the column values. """ nwItem = self.theProject.projTree[tHandle] novIdx = self.theIndex.novelIndex[tHandle][sTitle] newItem = QTreeWidgetItem() hIcon = "doc_%s" % tLevel.lower() newItem.setText(self.colIndex[nwOutline.TITLE], novIdx["title"]) newItem.setData(self.colIndex[nwOutline.TITLE], Qt.UserRole, tHandle) newItem.setIcon(self.colIndex[nwOutline.TITLE], self.theTheme.getIcon(hIcon)) newItem.setText(self.colIndex[nwOutline.LEVEL], novIdx["level"]) newItem.setText(self.colIndex[nwOutline.LABEL], nwItem.itemName) newItem.setIcon(self.colIndex[nwOutline.LABEL], self.theTheme.getIcon("proj_document")) newItem.setText(self.colIndex[nwOutline.LINE], sTitle[1:].lstrip("0")) newItem.setData(self.colIndex[nwOutline.LINE], Qt.UserRole, sTitle) newItem.setText(self.colIndex[nwOutline.SYNOP], novIdx["synopsis"]) newItem.setText(self.colIndex[nwOutline.CCOUNT], str(novIdx["cCount"])) newItem.setText(self.colIndex[nwOutline.WCOUNT], str(novIdx["wCount"])) newItem.setText(self.colIndex[nwOutline.PCOUNT], str(novIdx["pCount"])) newItem.setTextAlignment(self.colIndex[nwOutline.CCOUNT], Qt.AlignRight) newItem.setTextAlignment(self.colIndex[nwOutline.WCOUNT], Qt.AlignRight) newItem.setTextAlignment(self.colIndex[nwOutline.PCOUNT], Qt.AlignRight) theRefs = self.theIndex.getReferences(tHandle, sTitle) newItem.setText(self.colIndex[nwOutline.POV], ", ".join(theRefs[nwKeyWords.POV_KEY])) newItem.setText(self.colIndex[nwOutline.CHAR], ", ".join(theRefs[nwKeyWords.CHAR_KEY])) newItem.setText(self.colIndex[nwOutline.PLOT], ", ".join(theRefs[nwKeyWords.PLOT_KEY])) newItem.setText(self.colIndex[nwOutline.TIME], ", ".join(theRefs[nwKeyWords.TIME_KEY])) newItem.setText(self.colIndex[nwOutline.WORLD], ", ".join(theRefs[nwKeyWords.WORLD_KEY])) newItem.setText(self.colIndex[nwOutline.OBJECT], ", ".join(theRefs[nwKeyWords.OBJECT_KEY])) newItem.setText(self.colIndex[nwOutline.ENTITY], ", ".join(theRefs[nwKeyWords.ENTITY_KEY])) newItem.setText(self.colIndex[nwOutline.CUSTOM], ", ".join(theRefs[nwKeyWords.CUSTOM_KEY])) return newItem # END Class GuiOutline class GuiOutlineHeaderMenu(QMenu): def __init__(self, theParent): QMenu.__init__(self, theParent) self.theParent = theParent self.acceptToggle = True mnuHead = QAction("Select Columns", self) self.addAction(mnuHead) self.addSeparator() self.actionMap = {} for hItem in nwOutline: if hItem == nwOutline.TITLE: continue self.actionMap[hItem] = QAction(nwLabels.OUTLINE_COLS[hItem], self) self.actionMap[hItem].setCheckable(True) self.actionMap[hItem].toggled.connect( lambda isChecked, tItem=hItem : self._columnToggled(isChecked, tItem) ) self.addAction(self.actionMap[hItem]) return def setHiddenState(self, hiddenState): """Overwrite the checked state of the columns as the inverse of the hidden state. Skip the TITLE column as it cannot be hidden. """ self.acceptToggle = False for hItem in nwOutline: if hItem == nwOutline.TITLE or hItem not in hiddenState: continue self.actionMap[hItem].setChecked(not hiddenState[hItem]) self.acceptToggle = True return ## # Slots ## def _columnToggled(self, isChecked, theItem): """The user has toggled the visibility of a column. Forward the event to the parent class only if we're accepting changes. """ if self.acceptToggle: self.theParent._menuColumnToggled(isChecked, theItem) return # END Class GuiOutlineHeaderMenu