From d4b26025edddd9bb667a5cbf95f7682a6e0deebd Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 20 Dec 2020 15:50:01 +0100 Subject: [PATCH 01/15] Add the basic structure for a novel tree --- nw/config.py | 29 +++-- nw/gui/__init__.py | 2 + nw/gui/noveltree.py | 119 ++++++++++++++++++ nw/gui/projtree.py | 6 +- nw/guimain.py | 58 +++++---- sample/nwProject.nwx | 8 +- tests/reference/baseConfig_novelwriter.conf | 1 + .../reference/guiPreferences_novelwriter.conf | 1 + tests/test_base_config.py | 13 ++ tests/test_gui_outline.py | 2 +- tests/test_gui_preferences.py | 9 +- 11 files changed, 206 insertions(+), 42 deletions(-) create mode 100644 nw/gui/noveltree.py diff --git a/nw/config.py b/nw/config.py index 9c232109..b5fb1ff8 100644 --- a/nw/config.py +++ b/nw/config.py @@ -95,14 +95,15 @@ class Config: self.lastNotes = "" # The latest release notes that have been shown ## Sizes - self.winGeometry = [1200, 650] - self.treeColWidth = [200, 50, 30] - self.projColWidth = [200, 60, 140] - self.mainPanePos = [300, 800] - self.docPanePos = [400, 400] - self.viewPanePos = [500, 150] - self.outlnPanePos = [500, 150] - self.isFullScreen = False + self.winGeometry = [1200, 650] + self.treeColWidth = [200, 50, 30] + self.novelColWidth = [200, 50] + self.projColWidth = [200, 60, 140] + self.mainPanePos = [300, 800] + self.docPanePos = [400, 400] + self.viewPanePos = [500, 150] + self.outlnPanePos = [500, 150] + self.isFullScreen = False ## Features self.hideVScroll = False # Hide vertical scroll bars on main widgets @@ -395,6 +396,9 @@ class Config: self.treeColWidth = self._parseLine( cnfParse, cnfSec, "treecols", self.CNF_I_LST, self.treeColWidth ) + self.novelColWidth = self._parseLine( + cnfParse, cnfSec, "novelcols", self.CNF_I_LST, self.novelColWidth + ) self.projColWidth = self._parseLine( cnfParse, cnfSec, "projcols", self.CNF_I_LST, self.projColWidth ) @@ -597,6 +601,7 @@ class Config: cnfParse.add_section(cnfSec) cnfParse.set(cnfSec, "geometry", self._packList(self.winGeometry)) cnfParse.set(cnfSec, "treecols", self._packList(self.treeColWidth)) + cnfParse.set(cnfSec, "novelcols", self._packList(self.novelColWidth)) cnfParse.set(cnfSec, "projcols", self._packList(self.projColWidth)) cnfParse.set(cnfSec, "mainpane", self._packList(self.mainPanePos)) cnfParse.set(cnfSec, "docpane", self._packList(self.docPanePos)) @@ -816,6 +821,11 @@ class Config: self.confChanged = True return True + def setNovelColWidths(self, colWidths): + self.novelColWidth = [int(x/self.guiScale) for x in colWidths] + self.confChanged = True + return True + def setProjColWidths(self, colWidths): self.projColWidth = [int(x/self.guiScale) for x in colWidths] self.confChanged = True @@ -872,6 +882,9 @@ class Config: def getTreeColWidths(self): return [int(x*self.guiScale) for x in self.treeColWidth] + def getNovelColWidths(self): + return [int(x*self.guiScale) for x in self.novelColWidth] + def getProjColWidths(self): return [int(x*self.guiScale) for x in self.projColWidth] diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py index d40d84fc..6b71867d 100644 --- a/nw/gui/__init__.py +++ b/nw/gui/__init__.py @@ -9,6 +9,7 @@ from nw.gui.docviewer import GuiDocViewer, GuiDocViewDetails from nw.gui.itemdetails import GuiItemDetails from nw.gui.itemeditor import GuiItemEditor from nw.gui.mainmenu import GuiMainMenu +from nw.gui.noveltree import GuiNovelTree from nw.gui.outline import GuiOutline from nw.gui.outlinedetails import GuiOutlineDetails from nw.gui.preferences import GuiPreferences @@ -31,6 +32,7 @@ __all__ = [ "GuiItemDetails", "GuiItemEditor", "GuiMainMenu", + "GuiNovelTree", "GuiMainStatus", "GuiOutline", "GuiOutlineDetails", diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py new file mode 100644 index 00000000..35dd6632 --- /dev/null +++ b/nw/gui/noveltree.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""novelWriter GUI Novel Tree + + novelWriter – GUI Novel Tree +============================== + Class holding the project's novel files tree view + + 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 nw +import logging + +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtWidgets import QTreeWidget + +logger = logging.getLogger(__name__) + +class GuiNovelTree(QTreeWidget): + + C_TITLE = 0 + C_WORDS = 1 + C_PAGES = 2 + + def __init__(self, theParent): + QTreeWidget.__init__(self, theParent) + + logger.debug("Initialising GuiNovelTree ...") + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theTheme = theParent.theTheme + self.theProject = theParent.theProject + self.theIndex = theParent.theIndex + + # Build GUI + iPx = self.theTheme.baseIconSize + self.setIconSize(QSize(iPx, iPx)) + self.setExpandsOnDoubleClick(True) + self.setIndentation(iPx) + self.setColumnCount(3) + self.setHeaderLabels(["Title", "Words", "Pages"]) + + # Get user's column width preferences for NAME and COUNT + treeColWidth = self.mainConf.getNovelColWidths() + if len(treeColWidth) <= 3: + for colN, colW in enumerate(treeColWidth): + self.setColumnWidth(colN, colW) + + # The last column should just auto-scale + self.resizeColumnToContents(self.C_PAGES) + + # Set custom settings + self.initTree() + + logger.debug("GuiNovelTree initialisation complete") + + # Internal Mapping + self.makeAlert = self.theParent.makeAlert + + return + + def initTree(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() + return + + def getColumnSizes(self): + """Return the column widths for the tree columns. + """ + retVals = [ + self.columnWidth(self.C_TITLE), + self.columnWidth(self.C_WORDS), + ] + return retVals + + ## + # Slots + ## + +# END Class GuiNovelTree diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 2c52c4cb..d9a03c04 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -357,9 +357,9 @@ class GuiProjectTree(QTreeWidget): """Return the column widths for the tree columns. """ retVals = [ - self.columnWidth(0), - self.columnWidth(1), - self.columnWidth(2), + self.columnWidth(self.C_NAME), + self.columnWidth(self.C_COUNT), + self.columnWidth(self.C_EXPORT), ] return retVals diff --git a/nw/guimain.py b/nw/guimain.py index 4361ae8f..82a7310a 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -42,9 +42,9 @@ from PyQt5.QtWidgets import ( from nw.gui import ( GuiAbout, GuiBuildNovel, GuiDocEditor, GuiDocMerge, GuiDocSplit, GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor, - GuiMainMenu, GuiMainStatus, GuiOutline, GuiOutlineDetails, GuiPreferences, - GuiProjectLoad, GuiProjectSettings, GuiProjectTree, GuiProjectWizard, - GuiTheme, GuiWritingStats + GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails, + GuiPreferences, GuiProjectLoad, GuiProjectSettings, GuiProjectTree, + GuiProjectWizard, GuiTheme, GuiWritingStats ) from nw.core import NWProject, NWDoc, NWIndex from nw.constants import nwItemType, nwItemClass, nwAlert @@ -99,6 +99,7 @@ class GuiMain(QMainWindow): # Main GUI Elements self.statusBar = GuiMainStatus(self) self.treeView = GuiProjectTree(self) + self.novelView = GuiNovelTree(self) self.docEditor = GuiDocEditor(self) self.viewMeta = GuiDocViewDetails(self) self.docViewer = GuiDocViewer(self) @@ -111,11 +112,23 @@ class GuiMain(QMainWindow): self.statusIcons = [] self.importIcons = [] + # Project Tabs : Project / Novel + self.projTabs = QTabWidget() + self.projTabs.setTabPosition(QTabWidget.South) + self.projTabs.setStyleSheet("QTabWidget::pane {border: 0;};") + self.projTabs.addTab(self.treeView, "Project") + self.projTabs.addTab(self.novelView, "Novel") + + tabFont = self.projTabs.tabBar().font() + tabFont.setPointSize(round(0.9*self.theTheme.fontPointSize)) + self.projTabs.tabBar().setFont(tabFont) + # Project Tree View self.treePane = QWidget() self.treeBox = QVBoxLayout() self.treeBox.setContentsMargins(0, 0, 0, 0) - self.treeBox.addWidget(self.treeView) + self.treeBox.setSpacing(0) + self.treeBox.addWidget(self.projTabs) self.treeBox.addWidget(self.treeMeta) self.treePane.setLayout(self.treeBox) @@ -136,31 +149,31 @@ class GuiMain(QMainWindow): self.splitOutline.addWidget(self.projMeta) self.splitOutline.setSizes(self.mainConf.getOutlinePanePos()) - # Main Tabs : Edirot / Outline - self.tabWidget = QTabWidget() - self.tabWidget.setTabPosition(QTabWidget.East) - self.tabWidget.setStyleSheet("QTabWidget::pane {border: 0;}") - self.tabWidget.addTab(self.splitDocs, "Editor") - self.tabWidget.addTab(self.splitOutline, "Outline") - self.tabWidget.currentChanged.connect(self._mainTabChanged) + # Main Tabs : Editor / Outline + self.mainTabs = QTabWidget() + self.mainTabs.setTabPosition(QTabWidget.East) + self.mainTabs.setStyleSheet("QTabWidget::pane {border: 0;}") + self.mainTabs.addTab(self.splitDocs, "Editor") + self.mainTabs.addTab(self.splitOutline, "Outline") + self.mainTabs.currentChanged.connect(self._mainTabChanged) # Splitter : Project Tree / Main Tabs xCM = self.mainConf.pxInt(4) self.splitMain = QSplitter(Qt.Horizontal) self.splitMain.setContentsMargins(xCM, xCM, xCM, xCM) self.splitMain.addWidget(self.treePane) - self.splitMain.addWidget(self.tabWidget) + self.splitMain.addWidget(self.mainTabs) self.splitMain.setSizes(self.mainConf.getMainPanePos()) # Indices of All Splitter Widgets self.idxTree = self.splitMain.indexOf(self.treePane) - self.idxMain = self.splitMain.indexOf(self.tabWidget) + self.idxMain = self.splitMain.indexOf(self.mainTabs) self.idxEditor = self.splitDocs.indexOf(self.docEditor) self.idxViewer = self.splitDocs.indexOf(self.splitView) self.idxViewDoc = self.splitView.indexOf(self.docViewer) self.idxViewMeta = self.splitView.indexOf(self.viewMeta) - self.idxTabEdit = self.tabWidget.indexOf(self.splitDocs) - self.idxTabProj = self.tabWidget.indexOf(self.splitOutline) + self.idxTabEdit = self.mainTabs.indexOf(self.splitDocs) + self.idxTabProj = self.mainTabs.indexOf(self.splitOutline) # Splitter Behaviour self.splitMain.setCollapsible(self.idxTree, False) @@ -355,7 +368,7 @@ class GuiMain(QMainWindow): self.theIndex.clearIndex() self.clearGUI() self.hasProject = False - self.tabWidget.setCurrentWidget(self.splitDocs) + self.mainTabs.setCurrentWidget(self.splitDocs) return saveOK @@ -371,7 +384,7 @@ class GuiMain(QMainWindow): return False # Switch main tab to editor view - self.tabWidget.setCurrentWidget(self.splitDocs) + self.mainTabs.setCurrentWidget(self.splitDocs) # Try to open the project if not self.theProject.openProject(projFile): @@ -490,7 +503,7 @@ class GuiMain(QMainWindow): return False self.closeDocument() - self.tabWidget.setCurrentWidget(self.splitDocs) + self.mainTabs.setCurrentWidget(self.splitDocs) if self.docEditor.loadText(tHandle, tLine): if changeFocus: self.docEditor.setFocus() @@ -575,7 +588,7 @@ class GuiMain(QMainWindow): return False # Make sure main tab is in Editor view - self.tabWidget.setCurrentWidget(self.splitDocs) + self.mainTabs.setCurrentWidget(self.splitDocs) logger.debug("Viewing document with handle %s" % tHandle) if self.docViewer.loadText(tHandle): @@ -797,7 +810,7 @@ class GuiMain(QMainWindow): return False logger.verbose("Forcing a rebuild of the Project Outline") - self.tabWidget.setCurrentWidget(self.splitOutline) + self.mainTabs.setCurrentWidget(self.splitOutline) self.projView.refreshTree(overRide=True) return True @@ -1022,6 +1035,7 @@ class GuiMain(QMainWindow): self.mainConf.setShowRefPanel(self.viewMeta.isVisible()) self.mainConf.setTreeColWidths(self.treeView.getColumnSizes()) + self.mainConf.setNovelColWidths(self.novelView.getColumnSizes()) if not self.mainConf.isFullScreen: self.mainConf.setWinSize(self.width(), self.height()) @@ -1078,7 +1092,7 @@ class GuiMain(QMainWindow): self.mainMenu.aFocusMode.setChecked(self.isFocusMode) if self.isFocusMode: logger.debug("Activating Focus Mode") - self.tabWidget.setCurrentWidget(self.splitDocs) + self.mainTabs.setCurrentWidget(self.splitDocs) else: logger.debug("Deactivating Focus Mode") @@ -1086,7 +1100,7 @@ class GuiMain(QMainWindow): self.treePane.setVisible(isVisible) self.statusBar.setVisible(isVisible) self.mainMenu.setVisible(isVisible) - self.tabWidget.tabBar().setVisible(isVisible) + self.mainTabs.tabBar().setVisible(isVisible) hideDocFooter = self.isFocusMode and self.mainConf.hideFocusFooter self.docEditor.docFooter.setVisible(not hideDocFooter) diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 4a7f45d3..c954192c 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,13 +1,13 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 810 - 153 - 39261 + 836 + 155 + 39747 False diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 5ff6f852..27f2062d 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -11,6 +11,7 @@ lastnotes = 1.0 [Sizes] geometry = 1200, 650 treecols = 200, 50, 30 +novelcols = 200, 50 projcols = 200, 60, 140 mainpane = 300, 800 docpane = 400, 400 diff --git a/tests/reference/guiPreferences_novelwriter.conf b/tests/reference/guiPreferences_novelwriter.conf index cdbc5337..5b10156f 100644 --- a/tests/reference/guiPreferences_novelwriter.conf +++ b/tests/reference/guiPreferences_novelwriter.conf @@ -11,6 +11,7 @@ lastnotes = 1.0 [Sizes] geometry = 1100, 650 treecols = 120, 30, 50 +novelcols = 200, 50 projcols = 140, 55, 140 mainpane = 300, 800 docpane = 400, 400 diff --git a/tests/test_base_config.py b/tests/test_base_config.py index fe755646..bc9dd161 100644 --- a/tests/test_base_config.py +++ b/tests/test_base_config.py @@ -304,6 +304,19 @@ def testBaseConfig_SettersGetters(tmpConf, tmpDir, outDir, refDir): assert tmpConf.setTreeColWidths([200, 50, 30]) + # Novel Tree Columns + tmpConf.guiScale = 2.0 + assert tmpConf.setNovelColWidths([10, 20]) + assert tmpConf.getNovelColWidths() == [10, 20] + assert tmpConf.novelColWidth == [5, 10] + + tmpConf.guiScale = 1.0 + assert tmpConf.setNovelColWidths([10, 20]) + assert tmpConf.getNovelColWidths() == [10, 20] + assert tmpConf.novelColWidth == [10, 20] + + assert tmpConf.setNovelColWidths([200, 50]) + # Project Settings Tree Columns tmpConf.guiScale = 2.0 assert tmpConf.setProjColWidths([10, 20, 30]) diff --git a/tests/test_gui_outline.py b/tests/test_gui_outline.py index cf93c111..906481c5 100644 --- a/tests/test_gui_outline.py +++ b/tests/test_gui_outline.py @@ -24,7 +24,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.mainConf.lastPath = nwLipsum nwGUI.rebuildIndex() - nwGUI.tabWidget.setCurrentIndex(nwGUI.idxTabProj) + nwGUI.mainTabs.setCurrentIndex(nwGUI.idxTabProj) assert nwGUI.projView.topLevelItemCount() > 0 diff --git a/tests/test_gui_preferences.py b/tests/test_gui_preferences.py index 4c8b17ea..9c76d830 100644 --- a/tests/test_gui_preferences.py +++ b/tests/test_gui_preferences.py @@ -220,10 +220,11 @@ def testGuiPreferences_Main(qtbot, monkeypatch, fncDir, outDir, refDir): compFile = os.path.join(refDir, "guiPreferences_novelwriter.conf") copyfile(projFile, testFile) ignoreLines = [ - 2, # Timestamp - 9, # Release Notes - 12, 13, 14, 15, 16, 17, 18, # Window sizes - 7, 28, # Fonts (depends on system default) + 2, # Timestamp + 9, # Release Notes + 12, 13, 14, 15, # Window sizes + 16, 17, 18, 19, # Window sizes + 7, 29, # Fonts (depends on system default) ] assert cmpFiles(testFile, compFile, ignoreLines) From 1d12014399f6b13f1f6445fa0bfa0c358eeb0959 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 20 Dec 2020 16:27:42 +0100 Subject: [PATCH 02/15] The basic functionality of the novel tree view is now in place --- nw/gui/noveltree.py | 152 +++++++++++++++++++++++++++++++++++++++-- nw/gui/projsettings.py | 2 +- nw/gui/projtree.py | 6 +- nw/guimain.py | 10 +-- 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py index 35dd6632..d44d2bc0 100644 --- a/nw/gui/noveltree.py +++ b/nw/gui/noveltree.py @@ -28,8 +28,12 @@ import nw import logging +from time import time + from PyQt5.QtCore import Qt, QSize -from PyQt5.QtWidgets import QTreeWidget +from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView + +from nw.constants import nwKeyWords logger = logging.getLogger(__name__) @@ -37,7 +41,7 @@ class GuiNovelTree(QTreeWidget): C_TITLE = 0 C_WORDS = 1 - C_PAGES = 2 + C_POV = 2 def __init__(self, theParent): QTreeWidget.__init__(self, theParent) @@ -50,13 +54,28 @@ class GuiNovelTree(QTreeWidget): self.theProject = theParent.theProject self.theIndex = theParent.theIndex + # Tree State + self.lastBuild = 0 + self.treeMap = {} + # Build GUI iPx = self.theTheme.baseIconSize self.setIconSize(QSize(iPx, iPx)) - self.setExpandsOnDoubleClick(True) self.setIndentation(iPx) self.setColumnCount(3) - self.setHeaderLabels(["Title", "Words", "Pages"]) + self.setHeaderLabels(["Title", "Words", "POV"]) + self.itemDoubleClicked.connect(self._treeDoubleClick) + self.itemSelectionChanged.connect(self._itemSelected) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionMode(QAbstractItemView.SingleSelection) + self.setExpandsOnDoubleClick(False) + self.setDragEnabled(False) + + treeHeadItem = self.headerItem() + treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) + treeHeadItem.setToolTip(self.C_TITLE, "Section title") + treeHeadItem.setToolTip(self.C_WORDS, "Word count") + treeHeadItem.setToolTip(self.C_POV, "Point-of-view character") # Get user's column width preferences for NAME and COUNT treeColWidth = self.mainConf.getNovelColWidths() @@ -65,7 +84,7 @@ class GuiNovelTree(QTreeWidget): self.setColumnWidth(colN, colW) # The last column should just auto-scale - self.resizeColumnToContents(self.C_PAGES) + self.resizeColumnToContents(self.C_POV) # Set custom settings self.initTree() @@ -101,14 +120,21 @@ class GuiNovelTree(QTreeWidget): """Clear the GUI content and the related maps. """ self.clear() + self.treeMap = {} + return + + def refreshTree(self, overRide=False): + """Called whenever the Novel tab is activated. + """ + self._populateTree() return def getColumnSizes(self): """Return the column widths for the tree columns. """ retVals = [ - self.columnWidth(self.C_TITLE), - self.columnWidth(self.C_WORDS), + self.columnWidth(0), + self.columnWidth(1), ] return retVals @@ -116,4 +142,116 @@ class GuiNovelTree(QTreeWidget): # 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. + """ + theData = tItem.data(self.C_TITLE, Qt.UserRole) + tHandle = theData[0] + try: + tLine = int(theData[1]) + 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 tree meta panel. + """ + selItems = self.selectedItems() + if selItems: + tHandle = selItems[0].data(self.C_TITLE, Qt.UserRole)[0] + self.theParent.treeMeta.updateViewBox(tHandle) + + return + + ## + # Internal Functions + ## + + def _populateTree(self): + """Build the tree based on the project index. + """ + self.clear() + + 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. + """ + novIdx = self.theIndex.novelIndex[tHandle][sTitle] + + newItem = QTreeWidgetItem() + hIcon = "doc_%s" % tLevel.lower() + theData = (tHandle, sTitle[1:].lstrip("0")) + + wC = int(novIdx["wCount"]) + + newItem.setText(self.C_TITLE, novIdx["title"]) + newItem.setData(self.C_TITLE, Qt.UserRole, theData) + newItem.setIcon(self.C_TITLE, self.theTheme.getIcon(hIcon)) + newItem.setText(self.C_WORDS, f"{wC:n}") + newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) + + theRefs = self.theIndex.getReferences(tHandle, sTitle) + newItem.setText(self.C_POV, ", ".join(theRefs[nwKeyWords.POV_KEY])) + + return newItem + # END Class GuiNovelTree diff --git a/nw/gui/projsettings.py b/nw/gui/projsettings.py index e02fece6..dd07468c 100644 --- a/nw/gui/projsettings.py +++ b/nw/gui/projsettings.py @@ -118,7 +118,7 @@ class GuiProjectSettings(PagedDialog): self.theProject.setImportColours(importCol) if self.tabStatus.colChanged or self.tabImport.colChanged: - self.theParent.rebuildTree() + self.theParent.rebuildTrees() if self.tabReplace.arChanged: newList = self.tabReplace.getNewList() diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index d9a03c04..2c52c4cb 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -357,9 +357,9 @@ class GuiProjectTree(QTreeWidget): """Return the column widths for the tree columns. """ retVals = [ - self.columnWidth(self.C_NAME), - self.columnWidth(self.C_COUNT), - self.columnWidth(self.C_EXPORT), + self.columnWidth(0), + self.columnWidth(1), + self.columnWidth(2), ] return retVals diff --git a/nw/guimain.py b/nw/guimain.py index 82a7310a..e807a591 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -190,7 +190,7 @@ class GuiMain(QMainWindow): # Initialise the Project Tree self.treeView.itemSelectionChanged.connect(self._treeSingleClick) self.treeView.itemDoubleClicked.connect(self._treeDoubleClick) - self.rebuildTree() + self.rebuildTrees() # Set Main Window Elements self.setMenuBar(self.mainMenu) @@ -312,7 +312,7 @@ class GuiMain(QMainWindow): logger.info("Creating new project") if self.theProject.newProject(projData): - self.rebuildTree() + self.rebuildTrees() self.saveProject() self.hasProject = True self.statusBar.setRefTime(self.theProject.projOpened) @@ -436,7 +436,7 @@ class GuiMain(QMainWindow): # Update GUI self._setWindowTitle(self.theProject.projName) - self.rebuildTree() + self.rebuildTrees() self.docEditor.setDictionaries() self.docEditor.setSpellCheck(self.theProject.spellCheck) self.mainMenu.setAutoOutline(self.theProject.autoOutline) @@ -746,13 +746,15 @@ class GuiMain(QMainWindow): return - def rebuildTree(self): + def rebuildTrees(self): """Rebuild the project tree. """ self._makeStatusIcons() self._makeImportIcons() self.treeView.clearTree() self.treeView.buildTree() + self.novelView.clearTree() + self.novelView.refreshTree() return def rebuildIndex(self, beQuiet=False): From 2779c8e69fd3dfc0575eb5fe61a3525566755988 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 20 Dec 2020 16:50:01 +0100 Subject: [PATCH 03/15] Use build time to update novel tree, and restore last selected item on tab change --- nw/gui/noveltree.py | 29 ++++++++++++++++++++++++++--- nw/gui/projtree.py | 6 +----- nw/guimain.py | 38 ++++++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py index d44d2bc0..9c9e562b 100644 --- a/nw/gui/noveltree.py +++ b/nw/gui/noveltree.py @@ -126,7 +126,20 @@ class GuiNovelTree(QTreeWidget): def refreshTree(self, overRide=False): """Called whenever the Novel tab is activated. """ + if self.lastBuild >= self.theIndex.timeNovel: + logger.verbose("Novel tree more recent than the novel index: not updating") + return + + selItem = self.selectedItems() + titleKey = None + if selItem: + titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2] + self._populateTree() + + if titleKey is not None and titleKey in self.treeMap: + self.treeMap[titleKey].setSelected(True) + return def getColumnSizes(self): @@ -138,6 +151,16 @@ class GuiNovelTree(QTreeWidget): ] return retVals + def getSelectedHandle(self): + """Get the currently selected handle. If multiple items are + selected, return the first. + """ + selItem = self.selectedItems() + if selItem: + return selItem[0].data(self.C_TITLE, Qt.UserRole)[0] + + return None + ## # Slots ## @@ -193,7 +216,7 @@ class GuiNovelTree(QTreeWidget): continue tLevel = self.theIndex.novelIndex[tHandle][sTitle]["level"] - tItem = self._createTreeItem(tHandle, sTitle, tLevel) + tItem = self._createTreeItem(tHandle, sTitle, tLevel, titleKey) self.treeMap[titleKey] = tItem if tLevel == "H1": @@ -232,14 +255,14 @@ class GuiNovelTree(QTreeWidget): return - def _createTreeItem(self, tHandle, sTitle, tLevel): + def _createTreeItem(self, tHandle, sTitle, tLevel, titleKey): """Populate a tree item with all the column values. """ novIdx = self.theIndex.novelIndex[tHandle][sTitle] newItem = QTreeWidgetItem() hIcon = "doc_%s" % tLevel.lower() - theData = (tHandle, sTitle[1:].lstrip("0")) + theData = (tHandle, sTitle[1:].lstrip("0"), titleKey) wC = int(novIdx["wCount"]) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 2c52c4cb..290c4391 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -640,11 +640,7 @@ class GuiProjectTree(QTreeWidget): selected, return the first. """ selItem = self.selectedItems() - - if len(selItem) == 0: - return None - - if isinstance(selItem[0], QTreeWidgetItem): + if selItem: return selItem[0].data(self.C_NAME, Qt.UserRole) return None diff --git a/nw/guimain.py b/nw/guimain.py index e807a591..4efba144 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -118,6 +118,7 @@ class GuiMain(QMainWindow): self.projTabs.setStyleSheet("QTabWidget::pane {border: 0;};") self.projTabs.addTab(self.treeView, "Project") self.projTabs.addTab(self.novelView, "Novel") + self.projTabs.currentChanged.connect(self._projTabsChanged) tabFont = self.projTabs.tabBar().font() tabFont.setPointSize(round(0.9*self.theTheme.fontPointSize)) @@ -166,14 +167,16 @@ class GuiMain(QMainWindow): self.splitMain.setSizes(self.mainConf.getMainPanePos()) # Indices of All Splitter Widgets - self.idxTree = self.splitMain.indexOf(self.treePane) - self.idxMain = self.splitMain.indexOf(self.mainTabs) - self.idxEditor = self.splitDocs.indexOf(self.docEditor) - self.idxViewer = self.splitDocs.indexOf(self.splitView) - self.idxViewDoc = self.splitView.indexOf(self.docViewer) - self.idxViewMeta = self.splitView.indexOf(self.viewMeta) - self.idxTabEdit = self.mainTabs.indexOf(self.splitDocs) - self.idxTabProj = self.mainTabs.indexOf(self.splitOutline) + self.idxTree = self.splitMain.indexOf(self.treePane) + self.idxMain = self.splitMain.indexOf(self.mainTabs) + self.idxEditor = self.splitDocs.indexOf(self.docEditor) + self.idxViewer = self.splitDocs.indexOf(self.splitView) + self.idxViewDoc = self.splitView.indexOf(self.docViewer) + self.idxViewMeta = self.splitView.indexOf(self.viewMeta) + self.idxTabEdit = self.mainTabs.indexOf(self.splitDocs) + self.idxTabProj = self.mainTabs.indexOf(self.splitOutline) + self.idxTreeView = self.projTabs.indexOf(self.treeView) + self.idxNovelView = self.projTabs.indexOf(self.novelView) # Splitter Behaviour self.splitMain.setCollapsible(self.idxTree, False) @@ -1375,4 +1378,23 @@ class GuiMain(QMainWindow): self.projView.refreshTree() return + def _projTabsChanged(self, tabIndex): + """Activated when the project view tab is changed. + """ + tHandle = None + + if tabIndex == self.idxTreeView: + logger.verbose("Project tree tab activated") + tHandle = self.treeView.getSelectedHandle() + + elif tabIndex == self.idxNovelView: + logger.verbose("Novel tree tab activated") + if self.hasProject: + self.novelView.refreshTree() + tHandle = self.novelView.getSelectedHandle() + + self.treeMeta.updateViewBox(tHandle) + + return + # END Class GuiMain From e5ea8074d2bdf7dbb8f4705b268530c400b3ea43 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 20 Dec 2020 17:18:54 +0100 Subject: [PATCH 04/15] Copy the mouse press behaviour from the project tree to the novel tree --- nw/gui/noveltree.py | 33 +++++++++++++++++++++++++++++++++ nw/gui/projtree.py | 2 +- nw/guimain.py | 10 ++++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py index 9c9e562b..bbb3948f 100644 --- a/nw/gui/noveltree.py +++ b/nw/gui/noveltree.py @@ -77,6 +77,10 @@ class GuiNovelTree(QTreeWidget): treeHeadItem.setToolTip(self.C_WORDS, "Word count") treeHeadItem.setToolTip(self.C_POV, "Point-of-view character") + treeHeader = self.header() + treeHeader.setStretchLastSection(True) + treeHeader.setMinimumSectionSize(iPx + 6) + # Get user's column width preferences for NAME and COUNT treeColWidth = self.mainConf.getNovelColWidths() if len(treeColWidth) <= 3: @@ -161,6 +165,35 @@ class GuiNovelTree(QTreeWidget): return None + ## + # 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.theParent.viewDocument(tHandle) + + return + ## # Slots ## diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 290c4391..79e6dcd9 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -699,7 +699,7 @@ class GuiProjectTree(QTreeWidget): 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 suer middle clicked. + for viewing if the user middle-clicked. """ QTreeWidget.mousePressEvent(self, theEvent) diff --git a/nw/guimain.py b/nw/guimain.py index 4efba144..fb1808c9 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -269,6 +269,7 @@ class GuiMain(QMainWindow): """Wrapper function to clear all sub-elements of the main GUI. """ self.treeView.clearTree() + self.novelView.clearTree() self.docEditor.clearEditor() self.closeDocViewer() self.statusBar.clearStatus() @@ -878,6 +879,7 @@ class GuiMain(QMainWindow): self.docEditor.initEditor() self.docViewer.initViewer() self.treeView.initTree() + self.novelView.initTree() self.projView.initOutline() self.projMeta.initDetails() @@ -1381,19 +1383,19 @@ class GuiMain(QMainWindow): def _projTabsChanged(self, tabIndex): """Activated when the project view tab is changed. """ - tHandle = None + sHandle = None if tabIndex == self.idxTreeView: logger.verbose("Project tree tab activated") - tHandle = self.treeView.getSelectedHandle() + sHandle = self.treeView.getSelectedHandle() elif tabIndex == self.idxNovelView: logger.verbose("Novel tree tab activated") if self.hasProject: self.novelView.refreshTree() - tHandle = self.novelView.getSelectedHandle() + sHandle = self.novelView.getSelectedHandle() - self.treeMeta.updateViewBox(tHandle) + self.treeMeta.updateViewBox(sHandle) return From 3d5a5e4d86ba840d9ecf4ecffc48c755364947de Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Fri, 1 Jan 2021 15:08:11 +0100 Subject: [PATCH 05/15] Selecting outline item should also select it in project tree --- nw/gui/outline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nw/gui/outline.py b/nw/gui/outline.py index 4238cc44..75aa9dc2 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -227,6 +227,7 @@ class GuiOutline(QTreeWidget): 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) + self.theParent.treeView.setSelectedHandle(tHandle) return From 4d7f925c5a363b0a619c49246a86563a6afc668b Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 16:57:12 +0100 Subject: [PATCH 06/15] Added timestamp to project tree, and made some class variables internal in various classes --- nw/core/index.py | 51 +++++++++++++--------- nw/core/project.py | 4 +- nw/core/tree.py | 5 --- nw/gui/noveltree.py | 26 +++++++----- nw/gui/outline.py | 7 +-- nw/gui/projtree.py | 92 +++++++++++++++++++++------------------- nw/guimain.py | 2 - tests/test_core_index.py | 8 ++++ 8 files changed, 108 insertions(+), 87 deletions(-) diff --git a/nw/core/index.py b/nw/core/index.py index cb0ac376..d56429d3 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -51,18 +51,16 @@ class NWIndex(): self.indexBroken = False # Indices - self.tagIndex = None - self.refIndex = None - self.novelIndex = None - self.noteIndex = None - self.textCounts = None + self.tagIndex = {} + self.refIndex = {} + self.novelIndex = {} + self.noteIndex = {} + self.textCounts = {} # TimeStamps - self.timeNovel = 0 - self.timeNote = 0 - self.timeIndex = 0 - - self.clearIndex() + self._timeNovel = 0 + self._timeNotes = 0 + self._timeIndex = 0 return @@ -78,9 +76,9 @@ class NWIndex(): self.novelIndex = {} self.noteIndex = {} self.textCounts = {} - self.timeNovel = 0 - self.timeNote = 0 - self.timeIndex = 0 + self._timeNovel = 0 + self._timeNotes = 0 + self._timeIndex = 0 return def deleteHandle(self, tHandle): @@ -123,6 +121,21 @@ class NWIndex(): return True + def novelChangedSince(self, checkTime): + """Check if the novel index has changed since a given time. + """ + return self._timeNovel > checkTime + + def notesChangedSince(self, checkTime): + """Check if the notes index has changed since a given time. + """ + return self._timeNotes > checkTime + + def indexChangedSince(self, checkTime): + """Check if the index has changed since a given time. + """ + return self._timeIndex > checkTime + ## # Load and Save Index to/from File ## @@ -155,9 +168,9 @@ class NWIndex(): self.textCounts = theData["textCounts"] nowTime = round(time()) - self.timeNovel = nowTime - self.timeNote = nowTime - self.timeIndex = nowTime + self._timeNovel = nowTime + self._timeNotes = nowTime + self._timeIndex = nowTime self.checkIndex() @@ -334,11 +347,11 @@ class NWIndex(): # Update timestamps for index changes nowTime = round(time()) - self.timeIndex = nowTime + self._timeIndex = nowTime if isNovel: - self.timeNovel = nowTime + self._timeNovel = nowTime else: - self.timeNote = nowTime + self._timeNotes = nowTime return True diff --git a/nw/core/project.py b/nw/core/project.py index 1af00534..f14894cc 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -1025,7 +1025,7 @@ class NWProject(): by drag-and-drop. Forwarded to the NWTree class. """ if len(self.projTree) != len(newOrder): - logger.warning("Size of new and old tree order do not match") + logger.warning("Sizes of new and old tree order do not match") self.projTree.setOrder(newOrder) self.setProjectChanged(True) return True @@ -1341,7 +1341,7 @@ class NWProject(): if oLayout is None: oLayout = nwItemLayout.NOTE - if oParent is None or not self.projTree.handleExists(oParent): + if oParent is None or oParent not in self.projTree: oParent = self.projTree.findRoot(oClass) if oParent is None: oParent = self.projTree.findRoot(nwItemClass.NOVEL) diff --git a/nw/core/tree.py b/nw/core/tree.py index 0c59fd7f..43678b73 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -294,11 +294,6 @@ class NWTree(): tTree.append(tHandle) return tTree - def handleExists(self, tHandle): - """Check if a handle exists in the project. - """ - return tHandle in self._treeOrder - ## # Setters ## diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py index bbb3948f..163889d9 100644 --- a/nw/gui/noveltree.py +++ b/nw/gui/noveltree.py @@ -54,9 +54,9 @@ class GuiNovelTree(QTreeWidget): self.theProject = theParent.theProject self.theIndex = theParent.theIndex - # Tree State - self.lastBuild = 0 - self.treeMap = {} + # Internal Variables + self._treeMap = {} + self._lastBuild = 0 # Build GUI iPx = self.theTheme.baseIconSize @@ -124,14 +124,17 @@ class GuiNovelTree(QTreeWidget): """Clear the GUI content and the related maps. """ self.clear() - self.treeMap = {} + self._treeMap = {} + self._lastBuild = 0 return def refreshTree(self, overRide=False): """Called whenever the Novel tab is activated. """ - if self.lastBuild >= self.theIndex.timeNovel: - logger.verbose("Novel tree more recent than the novel index: not updating") + treeChanged = self.theParent.treeView.changedSince(self._lastBuild) + indexChanged = self.theIndex.novelChangedSince(self._lastBuild) + if not (treeChanged or indexChanged): + logger.verbose("No changes made to the novel") return selItem = self.selectedItems() @@ -139,10 +142,11 @@ class GuiNovelTree(QTreeWidget): if selItem: titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2] + self.theParent.treeView.flushTreeOrder() self._populateTree() - if titleKey is not None and titleKey in self.treeMap: - self.treeMap[titleKey].setSelected(True) + if titleKey is not None and titleKey in self._treeMap: + self._treeMap[titleKey].setSelected(True) return @@ -233,7 +237,7 @@ class GuiNovelTree(QTreeWidget): def _populateTree(self): """Build the tree based on the project index. """ - self.clear() + self.clearTree() for titleKey in self.theIndex.getNovelStructure(skipExcluded=True): @@ -250,7 +254,7 @@ class GuiNovelTree(QTreeWidget): tLevel = self.theIndex.novelIndex[tHandle][sTitle]["level"] tItem = self._createTreeItem(tHandle, sTitle, tLevel, titleKey) - self.treeMap[titleKey] = tItem + self._treeMap[titleKey] = tItem if tLevel == "H1": currTitle = tItem @@ -284,7 +288,7 @@ class GuiNovelTree(QTreeWidget): tItem.setExpanded(True) - self.lastBuild = time() + self._lastBuild = time() return diff --git a/nw/gui/outline.py b/nw/gui/outline.py index 75aa9dc2..cb259565 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -179,11 +179,8 @@ class GuiOutline(QTreeWidget): # 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 + idxChanged = self.theParent.theIndex.novelChangedSince(self.lastBuild) + doBuild = idxChanged and self.theProject.autoOutline if doBuild or overRide: logger.debug("Rebuilding Project Outline") self._populateTree() diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index bc5bbbe2..a74493ff 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -29,6 +29,8 @@ import nw import logging +from time import time + from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( @@ -60,22 +62,27 @@ class GuiProjectTree(QTreeWidget): self.theProject = theParent.theProject self.theIndex = theParent.theIndex - # Tree Settings - self.theMap = {} - self.treeChanged = False + # Internal Variables + self._treeMap = {} + self._treeChanged = False + self._timeChanged = 0 + ## + # Build GUI + ## + + # Context Menu self.ctxMenu = GuiProjectTreeMenu(self) - self.clearTree() + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._rightClickMenu) - # Build GUI + # Tree Settings iPx = self.theTheme.baseIconSize self.setIconSize(QSize(iPx, iPx)) self.setExpandsOnDoubleClick(True) self.setIndentation(iPx) self.setColumnCount(4) self.setHeaderLabels(["Label", "Words", "Inc", "Flags"]) - self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(self._rightClickMenu) treeHeadItem = self.headerItem() treeHeadItem.setTextAlignment(self.C_COUNT, Qt.AlignRight) @@ -102,7 +109,7 @@ class GuiProjectTree(QTreeWidget): # Set Multiple Selection by CTRL # Disabled for now, until the merge files option has been added # self.setSelectionMode(QAbstractItemView.ExtendedSelection) - # self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionBehavior(QAbstractItemView.SelectRows) # Get user's column width preferences for NAME and COUNT treeColWidth = self.mainConf.getTreeColWidths() @@ -116,10 +123,11 @@ class GuiProjectTree(QTreeWidget): # Set custom settings self.initTree() - logger.debug("GuiProjectTree initialisation complete") + # Internal Function Mapping + self.makeAlert = self.theParent.makeAlert + self.askQuestion = self.theParent.askQuestion - # Internal Mapping - self.makeAlert = self.theParent.makeAlert + logger.debug("GuiProjectTree initialisation complete") return @@ -147,8 +155,9 @@ class GuiProjectTree(QTreeWidget): """Clear the GUI content and the related map. """ self.clear() - self.theMap = {} - self.treeChanged = False + self._treeMap = {} + self._treeChanged = False + self._timeChanged = 0 return def newTreeItem(self, itemType, itemClass): @@ -274,8 +283,8 @@ class GuiProjectTree(QTreeWidget): return False pHandle = nwItem.itemParent - if pHandle is not None and pHandle in self.theMap: - self.theMap[pHandle].setExpanded(True) + if pHandle is not None and pHandle in self._treeMap: + self._treeMap[pHandle].setExpanded(True) self.clearSelection() trItem.setSelected(True) return True @@ -338,7 +347,7 @@ class GuiProjectTree(QTreeWidget): """Calls saveTreeOrder if there are unsaved changes, otherwise does nothing. """ - if self.treeChanged: + if self._treeChanged: logger.verbose("Flushing project tree to project class") self.saveTreeOrder() self._setTreeChanged(False) @@ -391,7 +400,7 @@ class GuiProjectTree(QTreeWidget): self.makeAlert("The Trash folder is already empty.", nwAlert.INFO) return False - msgYes = self.theParent.askQuestion( + msgYes = self.askQuestion( "Empty Trash", "Permanently delete %d file(s) from Trash?" % nTrash ) if not msgYes: @@ -446,7 +455,7 @@ class GuiProjectTree(QTreeWidget): # user if they want to permanently delete the file. doPermanent = False if not alreadyAsked: - msgYes = self.theParent.askQuestion( + msgYes = self.askQuestion( "Delete File", "Permanently delete file '%s'?" % nwItemS.itemName ) if msgYes: @@ -474,7 +483,7 @@ class GuiProjectTree(QTreeWidget): # move it there. doTrash = False if askForTrash: - msgYes = self.theParent.askQuestion( + msgYes = self.askQuestion( "Delete File", "Move file '%s' to Trash?" % nwItemS.itemName ) if msgYes: @@ -533,7 +542,9 @@ class GuiProjectTree(QTreeWidget): return True def setTreeItemValues(self, tHandle): - """Set the name and flag values for a tree item. + """Set the name and flag values for a tree item from a handle in + the project tree. Does not trigger a tree change as the data is + already coming from the project tree. """ trItem = self._getTreeItem(tHandle) nwItem = self.theProject.projTree[tHandle] @@ -622,9 +633,9 @@ class GuiProjectTree(QTreeWidget): sent first. """ logger.debug("Building the project tree ...") - self.clear() - iCount = 0 + self.clearTree() + iCount = 0 for nwItem in self.theProject.getProjectItems(): iCount += 1 self._addTreeItem(nwItem) @@ -642,21 +653,10 @@ class GuiProjectTree(QTreeWidget): return None - def getSelectedHandles(self): - """Return a list of all currently selected item handles. - """ - selItems = self.selectedItems() - selHandles = [] - for n in range(len(selItems)): - if isinstance(selItems[n], QTreeWidgetItem): - selHandles.append(selItems[n].data(self.C_NAME, Qt.UserRole)) - - return selHandles - def setSelectedHandle(self, tHandle, doScroll=False): """Set a specific handle as the selected item. """ - if tHandle not in self.theMap: + if tHandle not in self._treeMap: return False tItem = self._getTreeItem(tHandle) @@ -664,7 +664,7 @@ class GuiProjectTree(QTreeWidget): return False self.clearSelection() - self.theMap[tHandle].setSelected(True) + self._treeMap[tHandle].setSelected(True) selItems = self.selectedIndexes() if selItems and doScroll: @@ -672,6 +672,11 @@ class GuiProjectTree(QTreeWidget): return True + def changedSince(self, checkTime): + """Check if the tree has changed since a given time. + """ + return self._timeChanged > checkTime + ## # Slots ## @@ -797,7 +802,7 @@ class GuiProjectTree(QTreeWidget): def _getTreeItem(self, tHandle): """Returns the QTreeWidgetItem of a given item handle. """ - return self.theMap.get(tHandle, None) + return self._treeMap.get(tHandle, None) def _scanChildren(self, theList, theItem, theIndex): """This is a recursive function returning all items in a tree @@ -834,7 +839,7 @@ class GuiProjectTree(QTreeWidget): newItem.setData(self.C_NAME, Qt.UserRole, tHandle) newItem.setData(self.C_COUNT, Qt.UserRole, 0) - self.theMap[tHandle] = newItem + self._treeMap[tHandle] = newItem if pHandle is None: if nwItem.itemType == nwItemType.ROOT: self.addTopLevelItem(newItem) @@ -845,20 +850,20 @@ class GuiProjectTree(QTreeWidget): self.makeAlert( "There is nowhere to add item with name '%s'" % nwItem.itemName, nwAlert.ERROR ) - del self.theMap[tHandle] + del self._treeMap[tHandle] return None else: byIndex = -1 - if nHandle is not None and nHandle in self.theMap: + if nHandle is not None and nHandle in self._treeMap: try: - byIndex = self.theMap[pHandle].indexOfChild(self.theMap[nHandle]) + byIndex = self._treeMap[pHandle].indexOfChild(self._treeMap[nHandle]) except Exception: logger.error("Failed to get index of item with handle %s" % nHandle) if byIndex >= 0: - self.theMap[pHandle].insertChild(byIndex+1, newItem) + self._treeMap[pHandle].insertChild(byIndex+1, newItem) else: - self.theMap[pHandle].addChild(newItem) + self._treeMap[pHandle].addChild(newItem) self.propagateCount(tHandle, nwItem.wordCount) self.setTreeItemValues(tHandle) @@ -919,8 +924,9 @@ class GuiProjectTree(QTreeWidget): def _setTreeChanged(self, theState): """Set the tree change flag, and propagate to the project. """ - self.treeChanged = theState + self._treeChanged = theState if theState: + self._timeChanged = time() self.theProject.setProjectChanged(True) return diff --git a/nw/guimain.py b/nw/guimain.py index 18ebfd4e..56185e89 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -761,9 +761,7 @@ class GuiMain(QMainWindow): """ self._makeStatusIcons() self._makeImportIcons() - self.treeView.clearTree() self.treeView.buildTree() - self.novelView.clearTree() self.novelView.refreshTree() return diff --git a/tests/test_core_index.py b/tests/test_core_index.py index ad87c7f3..8fd22d73 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -205,6 +205,10 @@ def testCoreIndex_CheckThese(nwMinimal, dummyGUI): nItem = theProject.projTree[nHandle] cItem = theProject.projTree[cHandle] + assert not theIndex.novelChangedSince(0) + assert not theIndex.notesChangedSince(0) + assert not theIndex.indexChangedSince(0) + assert theIndex.scanText(cHandle, ( "# Jane Smith\n" "@tag: Jane" @@ -216,6 +220,10 @@ def testCoreIndex_CheckThese(nwMinimal, dummyGUI): assert theIndex.tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Hello World!" + assert theIndex.novelChangedSince(0) + assert theIndex.notesChangedSince(0) + assert theIndex.indexChangedSince(0) + assert theIndex.checkThese([], cItem) == [] assert theIndex.checkThese(["@tag", "Jane"], cItem) == [True, True] assert theIndex.checkThese(["@tag", "John"], cItem) == [True, True] From 21d91193a2b557a54d4d56eb2d533d4d1b270162 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 17:33:53 +0100 Subject: [PATCH 07/15] Keep project tree and outline tree in sync --- nw/gui/outline.py | 10 +++++----- nw/gui/projtree.py | 29 +++++++++++++++++++++++++++-- nw/guimain.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/nw/gui/outline.py b/nw/gui/outline.py index cb259565..c2ab5fa5 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -165,7 +165,7 @@ class GuiOutline(QTreeWidget): return - def refreshTree(self, overRide=False): + def refreshTree(self, overRide=False, novelChanged=False): """Called whenever the Outline tab is activated and controls what data to load, and if necessary, force a rebuild of the tree. @@ -177,10 +177,10 @@ class GuiOutline(QTreeWidget): self.firstView = False return - # If the novel index has changed since the tree was last built, - # we rebuild the tree from the updated index. - idxChanged = self.theParent.theIndex.novelChangedSince(self.lastBuild) - doBuild = idxChanged and self.theProject.autoOutline + # If the novel index or novel tree has changed since the tree + # was last built, we rebuild the tree from the updated index. + indexChanged = self.theIndex.novelChangedSince(self.lastBuild) + doBuild = (novelChanged or indexChanged) and self.theProject.autoOutline if doBuild or overRide: logger.debug("Rebuilding Project Outline") self._populateTree() diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index a74493ff..7180f9d1 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -31,7 +31,7 @@ import logging from time import time -from PyQt5.QtCore import Qt, QSize +from PyQt5.QtCore import Qt, QSize, pyqtSignal from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( qApp, QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction @@ -51,6 +51,9 @@ class GuiProjectTree(QTreeWidget): C_EXPORT = 2 C_FLAGS = 3 + novelItemChanged = pyqtSignal() + noteItemChanged = pyqtSignal() + def __init__(self, theParent): QTreeWidget.__init__(self, theParent) @@ -285,8 +288,11 @@ class GuiProjectTree(QTreeWidget): pHandle = nwItem.itemParent if pHandle is not None and pHandle in self._treeMap: self._treeMap[pHandle].setExpanded(True) + + self._emitItemChange(tHandle) self.clearSelection() trItem.setSelected(True) + return True def moveTreeItem(self, nStep): @@ -327,6 +333,7 @@ class GuiProjectTree(QTreeWidget): self.clearSelection() cItem.setSelected(True) self._setTreeChanged(True) + self._emitItemChange(tHandle) return True @@ -788,6 +795,10 @@ class GuiProjectTree(QTreeWidget): else: self.theIndex.reIndexHandle(sHandle) + # Trigger dependent updates + self._setTreeChanged(True) + self._emitItemChange(sHandle) + else: theEvent.ignore() logger.debug("Drag'n'drop of item %s not accepted" % sHandle) @@ -915,7 +926,6 @@ class GuiProjectTree(QTreeWidget): pHandle = trItemP.data(self.C_NAME, Qt.UserRole) nwItemS.setParent(pHandle) self.setTreeItemValues(tHandle) - self._setTreeChanged(True) logger.debug("The parent of item %s has been changed to %s" % (tHandle, pHandle)) @@ -930,6 +940,21 @@ class GuiProjectTree(QTreeWidget): self.theProject.setProjectChanged(True) return + def _emitItemChange(self, tHandle): + """Emit an item change signal for a given handle. + """ + nwItem = self.theProject.projTree[tHandle] + if nwItem is None: + return + + if nwItem.itemType == nwItemType.FILE: + if nwItem.itemClass == nwItemClass.NOVEL: + self.novelItemChanged.emit() + else: + self.noteItemChanged.emit() + + return + # END Class GuiProjectTree class GuiProjectTreeMenu(QMenu): diff --git a/nw/guimain.py b/nw/guimain.py index 56185e89..a2ce1444 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -32,7 +32,7 @@ import os from datetime import datetime from time import time -from PyQt5.QtCore import Qt, QTimer, QThreadPool +from PyQt5.QtCore import Qt, QTimer, QThreadPool, pyqtSlot from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence, QCursor from PyQt5.QtWidgets import ( qApp, QMainWindow, QVBoxLayout, QWidget, QSplitter, QFileDialog, QShortcut, @@ -193,6 +193,7 @@ class GuiMain(QMainWindow): # Initialise the Project Tree self.treeView.itemSelectionChanged.connect(self._treeSingleClick) self.treeView.itemDoubleClicked.connect(self._treeDoubleClick) + self.treeView.novelItemChanged.connect(self._treeNovelItemChanged) self.rebuildTrees() # Set Main Window Elements @@ -1319,9 +1320,10 @@ class GuiMain(QMainWindow): return ## - # Signal Handlers + # Slots ## + @pyqtSlot() def _treeSingleClick(self): """Single click on a project tree item just updates the details panel below the tree. @@ -1331,12 +1333,14 @@ class GuiMain(QMainWindow): self.treeMeta.updateViewBox(sHandle) return + @pyqtSlot("QTreeWidgetItem*", int) def _treeDoubleClick(self, tItem, colNo): """The user double-clicked an item in the tree. If it is a file, we open it. Otherwise, we do nothing. """ tHandle = tItem.data(self.treeView.C_NAME, Qt.UserRole) logger.verbose("User double clicked tree item with handle %s" % tHandle) + nwItem = self.theProject.projTree[tHandle] if nwItem is not None: if nwItem.itemType == nwItemType.FILE: @@ -1347,6 +1351,20 @@ class GuiMain(QMainWindow): return + @pyqtSlot() + def _treeNovelItemChanged(self): + """Triggered when there is a change to a novel item in the + project tree. + """ + if self.mainTabs.currentIndex() == self.idxTabProj: + logger.verbose("Novel tree changed while Outline tab active") + if self.hasProject: + self.treeView.flushTreeOrder() + self.projView.refreshTree(novelChanged=True) + + return + + @pyqtSlot() def _treeKeyPressReturn(self): """The user pressed return on an item in the tree. If it is a file, we open it. Otherwise, we do nothing. Pressing return does @@ -1354,6 +1372,7 @@ class GuiMain(QMainWindow): """ tHandle = self.treeView.getSelectedHandle() logger.verbose("User pressed return on tree item with handle %s" % tHandle) + nwItem = self.theProject.projTree[tHandle] if nwItem is not None: if nwItem.itemType == nwItemType.FILE: @@ -1361,8 +1380,10 @@ class GuiMain(QMainWindow): self.openDocument(tHandle, changeFocus=False, doScroll=False) else: logger.verbose("Requested item %s is a folder" % tHandle) + return + @pyqtSlot() def _keyPressEscape(self): """When the escape key is pressed somewhere in the main window, do the following, in order: @@ -1371,8 +1392,10 @@ class GuiMain(QMainWindow): self.docEditor.closeSearch() elif self.isFocusMode: self.toggleFocusMode() + return + @pyqtSlot(int) def _mainTabChanged(self, tabIndex): """Activated when the main window tab is changed. """ @@ -1382,8 +1405,10 @@ class GuiMain(QMainWindow): logger.verbose("Project outline tab activated") if self.hasProject: self.projView.refreshTree() + return + @pyqtSlot(int) def _projTabsChanged(self, tabIndex): """Activated when the project view tab is changed. """ From e4d160ee0b9a840a4904be7912f68975172ee15c Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 21:37:46 +0100 Subject: [PATCH 08/15] Make novel structure function in index class an iterable function instead --- nw/core/index.py | 16 +++++++--------- nw/gui/noveltree.py | 31 ++++++++----------------------- nw/gui/outline.py | 27 +++++++-------------------- tests/test_core_index.py | 27 +++++++++++++++++++++++---- 4 files changed, 45 insertions(+), 56 deletions(-) diff --git a/nw/core/index.py b/nw/core/index.py index d56429d3..d1bc7449 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -549,12 +549,11 @@ class NWIndex(): # Extract Data ## - def getNovelStructure(self, skipExcluded=True): - """Builds a list of all titles in the novel, in the correct - order as they appear in the tree view and in the respective - document files, but skipping all note files. + def novelStructure(self, skipExcluded=True): + """Iterate over all titles in the novel, in the correct order as + they appear in the tree view and in the respective document + files, but skipping all note files. """ - theStructure = [] for tItem in self.theProject.projTree: if tItem is not None: if not tItem.isExported and skipExcluded: @@ -562,10 +561,9 @@ class NWIndex(): tHandle = tItem.itemHandle if tHandle not in self.novelIndex: continue - for sTitle in sorted(self.novelIndex[tHandle].keys()): - theStructure.append("%s:%s" % (tHandle, sTitle)) - - return theStructure + for sTitle in sorted(self.novelIndex[tHandle]): + tKey = "%s:%s" % (tHandle, sTitle) + yield tKey, tHandle, sTitle, self.novelIndex[tHandle][sTitle] def getCounts(self, tHandle, sTitle=None): """Returns the counts for a file, or a section of a file diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py index 163889d9..b04339e8 100644 --- a/nw/gui/noveltree.py +++ b/nw/gui/noveltree.py @@ -34,6 +34,7 @@ from PyQt5.QtCore import Qt, QSize from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView from nw.constants import nwKeyWords +from nw.common import checkInt logger = logging.getLogger(__name__) @@ -209,10 +210,7 @@ class GuiNovelTree(QTreeWidget): """ theData = tItem.data(self.C_TITLE, Qt.UserRole) tHandle = theData[0] - try: - tLine = int(theData[1]) - except Exception: - tLine = 1 + tLine = checkInt(theData[1], 1) logger.verbose("User selected entry with handle %s on line %s" % (tHandle, tLine)) self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True) @@ -239,23 +237,12 @@ class GuiNovelTree(QTreeWidget): """ self.clearTree() - for titleKey in self.theIndex.getNovelStructure(skipExcluded=True): + for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(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, titleKey) - self._treeMap[titleKey] = tItem + tItem = self._createTreeItem(tHandle, sTitle, tKey, novIdx) + self._treeMap[tKey] = tItem + tLevel = novIdx["level"] if tLevel == "H1": currTitle = tItem self.addTopLevelItem(tItem) @@ -292,13 +279,11 @@ class GuiNovelTree(QTreeWidget): return - def _createTreeItem(self, tHandle, sTitle, tLevel, titleKey): + def _createTreeItem(self, tHandle, sTitle, titleKey, novIdx): """Populate a tree item with all the column values. """ - novIdx = self.theIndex.novelIndex[tHandle][sTitle] - newItem = QTreeWidgetItem() - hIcon = "doc_%s" % tLevel.lower() + hIcon = "doc_%s" % novIdx["level"].lower() theData = (tHandle, sTitle[1:].lstrip("0"), titleKey) wC = int(novIdx["wCount"]) diff --git a/nw/gui/outline.py b/nw/gui/outline.py index c2ab5fa5..fce5c6c9 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -375,23 +375,12 @@ class GuiOutline(QTreeWidget): currChapter = None currScene = None - for titleKey in self.theIndex.getNovelStructure(skipExcluded=True): + for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(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 + tItem = self._createTreeItem(tHandle, sTitle, novIdx) + self.treeMap[tKey] = tItem + tLevel = novIdx["level"] if tLevel == "H1": currTitle = tItem self.addTopLevelItem(tItem) @@ -428,14 +417,12 @@ class GuiOutline(QTreeWidget): return - def _createTreeItem(self, tHandle, sTitle, tLevel): + def _createTreeItem(self, tHandle, sTitle, novIdx): """Populate a tree item with all the column values. """ - nwItem = self.theProject.projTree[tHandle] - novIdx = self.theIndex.novelIndex[tHandle][sTitle] - + nwItem = self.theProject.projTree[tHandle] newItem = QTreeWidgetItem() - hIcon = "doc_%s" % tLevel.lower() + hIcon = "doc_%s" % novIdx["level"].lower() cC = int(novIdx["cCount"]) wC = int(novIdx["wCount"]) diff --git a/tests/test_core_index.py b/tests/test_core_index.py index 8fd22d73..d08cbd49 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -441,13 +441,32 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): )) # The novel structure should contain the pointer to the novel file header - assert theIndex.getNovelStructure() == ["%s:T000001" % nHandle] + theKeys = [] + for aKey, _, _, _ in theIndex.novelStructure(): + theKeys.append(aKey) + + assert theKeys == ["%s:T000001" % nHandle] # Check that excluded files can be skipped theProject.projTree[nHandle].setExported(False) - assert theIndex.getNovelStructure(skipExcluded=False) == ["%s:T000001" % nHandle] - assert theIndex.getNovelStructure(skipExcluded=True) == [] - assert theIndex.getNovelStructure() == [] + + theKeys = [] + for aKey, _, _, _ in theIndex.novelStructure(skipExcluded=False): + theKeys.append(aKey) + + assert theKeys == ["%s:T000001" % nHandle] + + theKeys = [] + for aKey, _, _, _ in theIndex.novelStructure(skipExcluded=True): + theKeys.append(aKey) + + assert theKeys == [] + + theKeys = [] + for aKey, _, _, _ in theIndex.novelStructure(): + theKeys.append(aKey) + + assert theKeys == [] # The novel file should have the correct counts cC, wC, pC = theIndex.getCounts(nHandle) From 24f951172651b5cec301119938009fe73e5a9ec0 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 22:04:08 +0100 Subject: [PATCH 09/15] Prevent items from ending up in the wrong order in outline and novel tree --- nw/gui/noveltree.py | 12 +++++++++++- nw/gui/outline.py | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py index b04339e8..1174bbfe 100644 --- a/nw/gui/noveltree.py +++ b/nw/gui/noveltree.py @@ -237,6 +237,10 @@ class GuiNovelTree(QTreeWidget): """ self.clearTree() + currTitle = None + currChapter = None + currScene = None + for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True): tItem = self._createTreeItem(tHandle, sTitle, tKey, novIdx) @@ -244,14 +248,19 @@ class GuiNovelTree(QTreeWidget): tLevel = novIdx["level"] if tLevel == "H1": - currTitle = tItem 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: @@ -261,6 +270,7 @@ class GuiNovelTree(QTreeWidget): else: currChapter.addChild(tItem) currScene = tItem + elif tLevel == "H4": if currScene is None: if currChapter is None: diff --git a/nw/gui/outline.py b/nw/gui/outline.py index fce5c6c9..3ff4d59a 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -382,14 +382,19 @@ class GuiOutline(QTreeWidget): tLevel = novIdx["level"] if tLevel == "H1": - currTitle = tItem 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: @@ -399,6 +404,7 @@ class GuiOutline(QTreeWidget): else: currChapter.addChild(tItem) currScene = tItem + elif tLevel == "H4": if currScene is None: if currChapter is None: From 4cb86b76e8931946018687f47151b5b22cbb0b4f Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 22:04:25 +0100 Subject: [PATCH 10/15] Add test of novel tree --- tests/test_gui_noveltree.py | 147 ++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/test_gui_noveltree.py diff --git a/tests/test_gui_noveltree.py b/tests/test_gui_noveltree.py new file mode 100644 index 00000000..d164bd6b --- /dev/null +++ b/tests/test_gui_noveltree.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +"""novelWriter Main GUI Project Tree Class Tester +""" + +import pytest +import os + +from tools import writeFile + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QMessageBox + +@pytest.mark.gui +def testGuiNovelTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): + """Test navigating the novel tree. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) + + nwGUI.openProject(nwMinimal) + nwGUI.theProject.projTree.setSeed(42) + nwTree = nwGUI.novelView + + ## + # Show/Hide Scrollbars + ## + + nwTree.mainConf.hideVScroll = True + nwTree.mainConf.hideHScroll = True + nwTree.initTree() + assert not nwTree.verticalScrollBar().isVisible() + assert not nwTree.horizontalScrollBar().isVisible() + + nwTree.mainConf.hideVScroll = False + nwTree.mainConf.hideHScroll = False + nwTree.initTree() + assert nwTree.verticalScrollBar().isEnabled() + assert nwTree.horizontalScrollBar().isEnabled() + + ## + # Populate Tree + ## + + nwGUI.projTabs.setCurrentIndex(nwGUI.idxNovelView) + + # The tree should be empty as there is no index + assert nwTree.topLevelItemCount() == 0 + + nwGUI.rebuildIndex() + nwTree._populateTree() + assert nwTree.topLevelItemCount() == 1 + + # Rebuild should preserve selection + topItem = nwTree.topLevelItem(0) + assert not topItem.isSelected() + topItem.setSelected(True) + assert nwTree.selectedItems()[0] == topItem + assert nwTree.getSelectedHandle() == "a35baf2e93843" + + nwTree.refreshTree() + assert nwTree.topLevelItem(0).isSelected() + + ## + # Open Items + ## + + # Clear selection + nwTree.clearSelection() + scItem = nwTree.topLevelItem(0).child(0).child(0) + scItem.setSelected(True) + assert scItem.isSelected() + + # Clear selection with mouse + vPort = nwTree.viewport() + qtbot.mouseClick(vPort, Qt.LeftButton, pos=vPort.rect().center(), delay=10) + assert not scItem.isSelected() + + # Double-click item + scItem.setSelected(True) + assert scItem.isSelected() + assert nwGUI.docEditor.theHandle is None + nwTree._treeDoubleClick(scItem, 0) + assert nwGUI.docEditor.theHandle == "8c659a11cd429" + + # Open item with middle mouse button + scItem.setSelected(True) + assert scItem.isSelected() + assert nwGUI.docViewer.theHandle is None + qtbot.mouseClick(vPort, Qt.MiddleButton, pos=vPort.rect().center(), delay=10) + assert nwGUI.docViewer.theHandle is None + + scRect = nwTree.visualItemRect(scItem) + oldData = scItem.data(nwTree.C_TITLE, Qt.UserRole) + scItem.setData(nwTree.C_TITLE, Qt.UserRole, (None, "", "")) + qtbot.mouseClick(vPort, Qt.MiddleButton, pos=scRect.center(), delay=10) + assert nwGUI.docViewer.theHandle is None + + scItem.setData(nwTree.C_TITLE, Qt.UserRole, oldData) + qtbot.mouseClick(vPort, Qt.MiddleButton, pos=scRect.center(), delay=10) + assert nwGUI.docViewer.theHandle == "8c659a11cd429" + + ## + # Populate Tree + ## + + # Add weird titles to first file to check hnadling of non-standard + # order of title levels. + writeFile(os.path.join(nwMinimal, "content", "a35baf2e93843.nwd"), ( + "#### Section wo/Scene\n\n" + "### Scene wo/Chapter\n\n" + "## Chapter wo/Title\n\n" + "# Title\n\n" + "#### Section w/Title, wo/Scene\n\n" + "### Scene w/Title, wo/Chapter\n\n" + "## Chapter\n\n" + "#### Section w/Chapter, wo/Scene\n\n" + "### Scene\n\n" + "#### Section\n\n" + )) + nwGUI.rebuildIndex() + nwTree._populateTree() + assert nwTree.topLevelItem(0).text(nwTree.C_TITLE) == "Section wo/Scene" + assert nwTree.topLevelItem(1).text(nwTree.C_TITLE) == "Scene wo/Chapter" + assert nwTree.topLevelItem(2).text(nwTree.C_TITLE) == "Chapter wo/Title" + assert nwTree.topLevelItem(3).text(nwTree.C_TITLE) == "Title" + + tTitle = nwTree.topLevelItem(3) + assert tTitle.child(0).text(nwTree.C_TITLE) == "Section w/Title, wo/Scene" + assert tTitle.child(1).text(nwTree.C_TITLE) == "Scene w/Title, wo/Chapter" + assert tTitle.child(2).text(nwTree.C_TITLE) == "Chapter" + + tChap = tTitle.child(2) + assert tChap.child(0).text(nwTree.C_TITLE) == "Section w/Chapter, wo/Scene" + assert tChap.child(1).text(nwTree.C_TITLE) == "Scene" + + tScene = tChap.child(1) + assert tScene.child(0).text(nwTree.C_TITLE) == "Section" + + ## + # Close + ## + + qtbot.stopForInteraction() + nwGUI.closeProject() + +# END Test testGuiNovelTree_TreeItems From a57e8f3a5d911c0635196c00208e2abd2cec18e4 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 22:05:17 +0100 Subject: [PATCH 11/15] Remove GUI block in test --- tests/test_gui_noveltree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gui_noveltree.py b/tests/test_gui_noveltree.py index d164bd6b..44b22688 100644 --- a/tests/test_gui_noveltree.py +++ b/tests/test_gui_noveltree.py @@ -141,7 +141,7 @@ def testGuiNovelTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): # Close ## - qtbot.stopForInteraction() + # qtbot.stopForInteraction() nwGUI.closeProject() # END Test testGuiNovelTree_TreeItems From acb6f1fc8781ec2213537eff0a9ac3531f2c2f21 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 22:22:05 +0100 Subject: [PATCH 12/15] Add novel data extraction function to index --- nw/core/index.py | 8 ++++++++ nw/gui/outlinedetails.py | 9 ++++----- tests/test_core_index.py | 7 +++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/nw/core/index.py b/nw/core/index.py index d1bc7449..6ace8988 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -611,6 +611,14 @@ class NWIndex(): return theRefs + def getNovelData(self, tHandle, sTitle): + """Return the novel data of a given handle and title. + """ + if tHandle in self.novelIndex: + if sTitle in self.novelIndex[tHandle]: + return self.novelIndex[tHandle][sTitle] + return None + def getBackReferenceList(self, tHandle): """Build a list of files referring back to our file, specified by tHandle. diff --git a/nw/gui/outlinedetails.py b/nw/gui/outlinedetails.py index c4e54f16..5e1d1105 100644 --- a/nw/gui/outlinedetails.py +++ b/nw/gui/outlinedetails.py @@ -271,11 +271,10 @@ class GuiOutlineDetails(QScrollArea): """Update the content of the tree with the given handle and line number pointing to a header. """ - try: - nwItem = self.theProject.projTree[tHandle] - novIdx = self.theIndex.novelIndex[tHandle][sTitle] - theRefs = self.theIndex.getReferences(tHandle, sTitle) - except Exception: + nwItem = self.theProject.projTree[tHandle] + novIdx = self.theIndex.getNovelData(tHandle, sTitle) + theRefs = self.theIndex.getReferences(tHandle, sTitle) + if nwItem is None or novIdx is None or theRefs == {}: return False if novIdx["level"] in self.LVL_MAP: diff --git a/tests/test_core_index.py b/tests/test_core_index.py index d08cbd49..cbb8ff8b 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -218,7 +218,7 @@ def testCoreIndex_CheckThese(nwMinimal, dummyGUI): "@pov: Jane" )) assert theIndex.tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} - assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Hello World!" + assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!" assert theIndex.novelChangedSince(0) assert theIndex.notesChangedSince(0) @@ -294,7 +294,7 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): "Well, not really.\n" )) assert str(theIndex.tagIndex) == "{'Jane': [2, '%s', 'CHARACTER', 'T000001']}" % cHandle - assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Hello World!" + assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!" # Check that title sections are indexed properly assert theIndex.scanText(nHandle, ( @@ -427,6 +427,9 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") + assert theIndex.getNovelData("", "") is None + assert theIndex.getNovelData("a508bb932959c", "") is None + assert theIndex.scanText(cHandle, ( "# Jane Smith\n" "@tag: Jane\n" From 1bdf0b55000c1b317bc7c85cea72975c8c9d0ec6 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 22:28:30 +0100 Subject: [PATCH 13/15] Make index dictionaries internal --- nw/core/index.py | 226 ++++++++++++++++++------------------ tests/test_core_index.py | 204 ++++++++++++++++---------------- tests/test_gui_docviewer.py | 8 +- 3 files changed, 219 insertions(+), 219 deletions(-) diff --git a/nw/core/index.py b/nw/core/index.py index 6ace8988..5838c767 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -51,11 +51,11 @@ class NWIndex(): self.indexBroken = False # Indices - self.tagIndex = {} - self.refIndex = {} - self.novelIndex = {} - self.noteIndex = {} - self.textCounts = {} + self._tagIndex = {} + self._refIndex = {} + self._novelIndex = {} + self._noteIndex = {} + self._textCounts = {} # TimeStamps self._timeNovel = 0 @@ -71,14 +71,14 @@ class NWIndex(): def clearIndex(self): """Clear the index dictionaries and time stamps. """ - self.tagIndex = {} - self.refIndex = {} - self.novelIndex = {} - self.noteIndex = {} - self.textCounts = {} - self._timeNovel = 0 - self._timeNotes = 0 - self._timeIndex = 0 + self._tagIndex = {} + self._refIndex = {} + self._novelIndex = {} + self._noteIndex = {} + self._textCounts = {} + self._timeNovel = 0 + self._timeNotes = 0 + self._timeIndex = 0 return def deleteHandle(self, tHandle): @@ -87,17 +87,17 @@ class NWIndex(): logger.debug("Removing item %s from the index" % tHandle) delTags = [] - for tTag in self.tagIndex: - if self.tagIndex[tTag][1] == tHandle: + for tTag in self._tagIndex: + if self._tagIndex[tTag][1] == tHandle: delTags.append(tTag) for tTag in delTags: - self.tagIndex.pop(tTag, None) + self._tagIndex.pop(tTag, None) - self.refIndex.pop(tHandle, None) - self.novelIndex.pop(tHandle, None) - self.noteIndex.pop(tHandle, None) - self.textCounts.pop(tHandle, None) + self._refIndex.pop(tHandle, None) + self._novelIndex.pop(tHandle, None) + self._noteIndex.pop(tHandle, None) + self._textCounts.pop(tHandle, None) return @@ -157,15 +157,15 @@ class NWIndex(): return False if "tagIndex" in theData.keys(): - self.tagIndex = theData["tagIndex"] + self._tagIndex = theData["tagIndex"] if "refIndex" in theData.keys(): - self.refIndex = theData["refIndex"] + self._refIndex = theData["refIndex"] if "novelIndex" in theData.keys(): - self.novelIndex = theData["novelIndex"] + self._novelIndex = theData["novelIndex"] if "noteIndex" in theData.keys(): - self.noteIndex = theData["noteIndex"] + self._noteIndex = theData["noteIndex"] if "textCounts" in theData.keys(): - self.textCounts = theData["textCounts"] + self._textCounts = theData["textCounts"] nowTime = round(time()) self._timeNovel = nowTime @@ -186,11 +186,11 @@ class NWIndex(): try: with open(indexFile, mode="w+", encoding="utf8") as outFile: json.dump({ - "tagIndex" : self.tagIndex, - "refIndex" : self.refIndex, - "novelIndex" : self.novelIndex, - "noteIndex" : self.noteIndex, - "textCounts" : self.textCounts, + "tagIndex" : self._tagIndex, + "refIndex" : self._refIndex, + "novelIndex" : self._novelIndex, + "noteIndex" : self._noteIndex, + "textCounts" : self._textCounts, }, outFile, indent=2) except Exception as e: logger.error("Failed to save index file") @@ -207,28 +207,28 @@ class NWIndex(): self.indexBroken = False try: - for tTag in self.tagIndex: - if len(self.tagIndex[tTag]) != 4: + for tTag in self._tagIndex: + if len(self._tagIndex[tTag]) != 4: self.indexBroken = True - for tHandle in self.refIndex: - for sTitle in self.refIndex[tHandle]: - for tEntry in self.refIndex[tHandle][sTitle]["tags"]: + for tHandle in self._refIndex: + for sTitle in self._refIndex[tHandle]: + for tEntry in self._refIndex[tHandle][sTitle]["tags"]: if len(tEntry) != 3: self.indexBroken = True - for tHandle in self.novelIndex: - for sLine in self.novelIndex[tHandle]: - if len(self.novelIndex[tHandle][sLine].keys()) != 8: + for tHandle in self._novelIndex: + for sLine in self._novelIndex[tHandle]: + if len(self._novelIndex[tHandle][sLine].keys()) != 8: self.indexBroken = True - for tHandle in self.noteIndex: - for sLine in self.noteIndex[tHandle]: - if len(self.noteIndex[tHandle][sLine].keys()) != 8: + for tHandle in self._noteIndex: + for sLine in self._noteIndex[tHandle]: + if len(self._noteIndex[tHandle][sLine].keys()) != 8: self.indexBroken = True - for tHandle in self.textCounts: - if len(self.textCounts[tHandle]) != 3: + for tHandle in self._textCounts: + if len(self._textCounts[tHandle]) != 3: self.indexBroken = True except Exception: @@ -271,7 +271,7 @@ class NWIndex(): # Run word counter for the whole text cC, wC, pC = countWords(theText) - self.textCounts[tHandle] = [cC, wC, pC] + self._textCounts[tHandle] = [cC, wC, pC] # If the file is archived or trashed, we don't index the file itself if self.theProject.projTree.isTrashRoot(theItem.itemParent): @@ -288,25 +288,25 @@ class NWIndex(): # Check file type, and reset its old index # Also add a dummy entry T000000 in case the file has no title - self.refIndex[tHandle] = {} - self.refIndex[tHandle]["T000000"] = { + self._refIndex[tHandle] = {} + self._refIndex[tHandle]["T000000"] = { "tags" : [], "updated" : round(time()), } if itemLayout == nwItemLayout.NOTE: - self.noteIndex[tHandle] = {} + self._noteIndex[tHandle] = {} isNovel = False else: - self.novelIndex[tHandle] = {} + self._novelIndex[tHandle] = {} isNovel = True # Also clear references to file in tag index clearTags = [] - for aTag in self.tagIndex: - if self.tagIndex[aTag][1] == tHandle: + for aTag in self._tagIndex: + if self._tagIndex[aTag][1] == tHandle: clearTags.append(aTag) for aTag in clearTags: - self.tagIndex.pop(aTag) + self._tagIndex.pop(aTag) nLine = 0 nTitle = 0 @@ -379,7 +379,7 @@ class NWIndex(): return False sTitle = "T%06d" % nLine - self.refIndex[tHandle][sTitle] = { + self._refIndex[tHandle][sTitle] = { "tags" : [], "updated" : round(time()), } @@ -396,11 +396,11 @@ class NWIndex(): if hText != "": if isNovel: - if tHandle in self.novelIndex: - self.novelIndex[tHandle][sTitle] = theData + if tHandle in self._novelIndex: + self._novelIndex[tHandle][sTitle] = theData else: - if tHandle in self.noteIndex: - self.noteIndex[tHandle][sTitle] = theData + if tHandle in self._noteIndex: + self._noteIndex[tHandle][sTitle] = theData return True @@ -410,19 +410,19 @@ class NWIndex(): cC, wC, pC = countWords(theText) sTitle = "T%06d" % nTitle if isNovel: - if tHandle in self.novelIndex: - if sTitle in self.novelIndex[tHandle]: - self.novelIndex[tHandle][sTitle]["cCount"] = cC - self.novelIndex[tHandle][sTitle]["wCount"] = wC - self.novelIndex[tHandle][sTitle]["pCount"] = pC - self.novelIndex[tHandle][sTitle]["updated"] = round(time()) + if tHandle in self._novelIndex: + if sTitle in self._novelIndex[tHandle]: + self._novelIndex[tHandle][sTitle]["cCount"] = cC + self._novelIndex[tHandle][sTitle]["wCount"] = wC + self._novelIndex[tHandle][sTitle]["pCount"] = pC + self._novelIndex[tHandle][sTitle]["updated"] = round(time()) else: - if tHandle in self.noteIndex: - if sTitle in self.noteIndex[tHandle]: - self.noteIndex[tHandle][sTitle]["cCount"] = cC - self.noteIndex[tHandle][sTitle]["wCount"] = wC - self.noteIndex[tHandle][sTitle]["pCount"] = pC - self.noteIndex[tHandle][sTitle]["updated"] = round(time()) + if tHandle in self._noteIndex: + if sTitle in self._noteIndex[tHandle]: + self._noteIndex[tHandle][sTitle]["cCount"] = cC + self._noteIndex[tHandle][sTitle]["wCount"] = wC + self._noteIndex[tHandle][sTitle]["pCount"] = pC + self._noteIndex[tHandle][sTitle]["updated"] = round(time()) return def _indexSynopsis(self, tHandle, isNovel, theText, nTitle): @@ -430,15 +430,15 @@ class NWIndex(): """ sTitle = "T%06d" % nTitle if isNovel: - if tHandle in self.novelIndex: - if sTitle in self.novelIndex[tHandle]: - self.novelIndex[tHandle][sTitle]["synopsis"] = theText - self.novelIndex[tHandle][sTitle]["updated"] = round(time()) + if tHandle in self._novelIndex: + if sTitle in self._novelIndex[tHandle]: + self._novelIndex[tHandle][sTitle]["synopsis"] = theText + self._novelIndex[tHandle][sTitle]["updated"] = round(time()) else: - if tHandle in self.noteIndex: - if sTitle in self.noteIndex[tHandle]: - self.noteIndex[tHandle][sTitle]["synopsis"] = theText - self.noteIndex[tHandle][sTitle]["updated"] = round(time()) + if tHandle in self._noteIndex: + if sTitle in self._noteIndex[tHandle]: + self._noteIndex[tHandle][sTitle]["synopsis"] = theText + self._noteIndex[tHandle][sTitle]["updated"] = round(time()) return def _indexNoteRef(self, tHandle, aLine, nLine, nTitle): @@ -450,9 +450,9 @@ class NWIndex(): return False sTitle = "T%06d" % nTitle - if sTitle in self.refIndex[tHandle] and theBits[0] != nwKeyWords.TAG_KEY: + if sTitle in self._refIndex[tHandle] and theBits[0] != nwKeyWords.TAG_KEY: for aVal in theBits[1:]: - self.refIndex[tHandle][sTitle]["tags"].append([nLine, theBits[0], aVal]) + self._refIndex[tHandle][sTitle]["tags"].append([nLine, theBits[0], aVal]) return True @@ -465,7 +465,7 @@ class NWIndex(): if theBits[0] == nwKeyWords.TAG_KEY: sTitle = "T%06d" % nTitle - self.tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle] + self._tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle] return True @@ -529,8 +529,8 @@ class NWIndex(): # is ignored if theBits[0] == nwKeyWords.TAG_KEY and nBits > 1: isGood[0] = True - if theBits[1] in self.tagIndex: - if self.tagIndex[theBits[1]][1] == tItem.itemHandle: + if theBits[1] in self._tagIndex: + if self._tagIndex[theBits[1]][1] == tItem.itemHandle: isGood[1] = True else: isGood[1] = False @@ -540,8 +540,8 @@ class NWIndex(): # If we're still here, we better check that the references exist for n in range(1, nBits): - if theBits[n] in self.tagIndex: - isGood[n] = nwKeyWords.KEY_CLASS[theBits[0]].name == self.tagIndex[theBits[n]][2] + if theBits[n] in self._tagIndex: + isGood[n] = nwKeyWords.KEY_CLASS[theBits[0]].name == self._tagIndex[theBits[n]][2] return isGood @@ -559,11 +559,11 @@ class NWIndex(): if not tItem.isExported and skipExcluded: continue tHandle = tItem.itemHandle - if tHandle not in self.novelIndex: + if tHandle not in self._novelIndex: continue - for sTitle in sorted(self.novelIndex[tHandle]): + for sTitle in sorted(self._novelIndex[tHandle]): tKey = "%s:%s" % (tHandle, sTitle) - yield tKey, tHandle, sTitle, self.novelIndex[tHandle][sTitle] + yield tKey, tHandle, sTitle, self._novelIndex[tHandle][sTitle] def getCounts(self, tHandle, sTitle=None): """Returns the counts for a file, or a section of a file @@ -574,21 +574,21 @@ class NWIndex(): pC = 0 if sTitle is None: - if tHandle in self.textCounts: - cC = self.textCounts[tHandle][0] - wC = self.textCounts[tHandle][1] - pC = self.textCounts[tHandle][2] + if tHandle in self._textCounts: + cC = self._textCounts[tHandle][0] + wC = self._textCounts[tHandle][1] + pC = self._textCounts[tHandle][2] else: - if tHandle in self.novelIndex: - if sTitle in self.novelIndex[tHandle]: - cC = self.novelIndex[tHandle][sTitle]["cCount"] - wC = self.novelIndex[tHandle][sTitle]["wCount"] - pC = self.novelIndex[tHandle][sTitle]["pCount"] - elif tHandle in self.noteIndex: - if sTitle in self.noteIndex[tHandle]: - cC = self.noteIndex[tHandle][sTitle]["cCount"] - wC = self.noteIndex[tHandle][sTitle]["wCount"] - pC = self.noteIndex[tHandle][sTitle]["pCount"] + if tHandle in self._novelIndex: + if sTitle in self._novelIndex[tHandle]: + cC = self._novelIndex[tHandle][sTitle]["cCount"] + wC = self._novelIndex[tHandle][sTitle]["wCount"] + pC = self._novelIndex[tHandle][sTitle]["pCount"] + elif tHandle in self._noteIndex: + if sTitle in self._noteIndex[tHandle]: + cC = self._noteIndex[tHandle][sTitle]["cCount"] + wC = self._noteIndex[tHandle][sTitle]["wCount"] + pC = self._noteIndex[tHandle][sTitle]["pCount"] return cC, wC, pC @@ -600,11 +600,11 @@ class NWIndex(): for tKey in nwKeyWords.KEY_CLASS: theRefs[tKey] = [] - if tHandle not in self.refIndex: + if tHandle not in self._refIndex: return theRefs - for refTitle in self.refIndex[tHandle]: - theTags = self.refIndex[tHandle][refTitle].get("tags", None) + for refTitle in self._refIndex[tHandle]: + theTags = self._refIndex[tHandle][refTitle].get("tags", None) for aTag in theTags: if len(aTag) == 3 and (sTitle is None or sTitle == refTitle): theRefs[aTag[1]].append(aTag[2]) @@ -614,9 +614,9 @@ class NWIndex(): def getNovelData(self, tHandle, sTitle): """Return the novel data of a given handle and title. """ - if tHandle in self.novelIndex: - if sTitle in self.novelIndex[tHandle]: - return self.novelIndex[tHandle][sTitle] + if tHandle in self._novelIndex: + if sTitle in self._novelIndex[tHandle]: + return self._novelIndex[tHandle][sTitle] return None def getBackReferenceList(self, tHandle): @@ -628,14 +628,14 @@ class NWIndex(): return theRefs theTags = set() - for tTag in self.tagIndex: - if tHandle == self.tagIndex[tTag][1]: + for tTag in self._tagIndex: + if tHandle == self._tagIndex[tTag][1]: theTags.add(tTag) if theTags: - for tHandle in self.refIndex: - for sTitle in self.refIndex[tHandle]: - for _, _, tTag in self.refIndex[tHandle][sTitle]["tags"]: + for tHandle in self._refIndex: + for sTitle in self._refIndex[tHandle]: + for _, _, tTag in self._refIndex[tHandle][sTitle]["tags"]: if tTag in theTags and tHandle not in theRefs: theRefs[tHandle] = sTitle @@ -644,8 +644,8 @@ class NWIndex(): def getTagSource(self, theTag): """Return the source location of a given tag. """ - if theTag in self.tagIndex: - theRef = self.tagIndex[theTag] + if theTag in self._tagIndex: + theRef = self._tagIndex[theTag] if len(theRef) == 4: return theRef[1], theRef[0], theRef[3] return None, 0, "T000000" diff --git a/tests/test_core_index.py b/tests/test_core_index.py index cbb8ff8b..b19af00d 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -56,30 +56,30 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, outDir, refDir): assert theIndex.saveIndex() # Take a copy of the index - tagIndex = str(theIndex.tagIndex) - refIndex = str(theIndex.refIndex) - novelIndex = str(theIndex.novelIndex) - noteIndex = str(theIndex.noteIndex) - textCounts = str(theIndex.textCounts) + tagIndex = str(theIndex._tagIndex) + refIndex = str(theIndex._refIndex) + novelIndex = str(theIndex._novelIndex) + noteIndex = str(theIndex._noteIndex) + textCounts = str(theIndex._textCounts) # Delete a handle - assert theIndex.tagIndex.get("Bod", None) is not None - assert theIndex.refIndex.get("4c4f28287af27", None) is not None - assert theIndex.noteIndex.get("4c4f28287af27", None) is not None - assert theIndex.textCounts.get("4c4f28287af27", None) is not None + assert theIndex._tagIndex.get("Bod", None) is not None + assert theIndex._refIndex.get("4c4f28287af27", None) is not None + assert theIndex._noteIndex.get("4c4f28287af27", None) is not None + assert theIndex._textCounts.get("4c4f28287af27", None) is not None theIndex.deleteHandle("4c4f28287af27") - assert theIndex.tagIndex.get("Bod", None) is None - assert theIndex.refIndex.get("4c4f28287af27", None) is None - assert theIndex.noteIndex.get("4c4f28287af27", None) is None - assert theIndex.textCounts.get("4c4f28287af27", None) is None + assert theIndex._tagIndex.get("Bod", None) is None + assert theIndex._refIndex.get("4c4f28287af27", None) is None + assert theIndex._noteIndex.get("4c4f28287af27", None) is None + assert theIndex._textCounts.get("4c4f28287af27", None) is None # Clear the index theIndex.clearIndex() - assert not theIndex.tagIndex - assert not theIndex.refIndex - assert not theIndex.novelIndex - assert not theIndex.noteIndex - assert not theIndex.textCounts + assert not theIndex._tagIndex + assert not theIndex._refIndex + assert not theIndex._novelIndex + assert not theIndex._noteIndex + assert not theIndex._textCounts # Make the load fail monkeypatch.setattr(json, "load", doPanic) @@ -89,46 +89,46 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, outDir, refDir): monkeypatch.undo() assert theIndex.loadIndex() - assert str(theIndex.tagIndex) == tagIndex - assert str(theIndex.refIndex) == refIndex - assert str(theIndex.novelIndex) == novelIndex - assert str(theIndex.noteIndex) == noteIndex - assert str(theIndex.textCounts) == textCounts + assert str(theIndex._tagIndex) == tagIndex + assert str(theIndex._refIndex) == refIndex + assert str(theIndex._novelIndex) == novelIndex + assert str(theIndex._noteIndex) == noteIndex + assert str(theIndex._textCounts) == textCounts # Break the index and check that we notice assert not theIndex.indexBroken - theIndex.tagIndex["Bod"].append("Stuff") # No longer len() == 4 + theIndex._tagIndex["Bod"].append("Stuff") # No longer len() == 4 theIndex.checkIndex() assert theIndex.indexBroken assert theIndex.loadIndex() assert not theIndex.indexBroken - theIndex.refIndex["fb609cd8319dc"]["T000001"]["tags"].append("Stuff") # No longer len() == 3 + theIndex._refIndex["fb609cd8319dc"]["T000001"]["tags"].append("Stuff") # No longer len() == 3 theIndex.checkIndex() assert theIndex.indexBroken assert theIndex.loadIndex() assert not theIndex.indexBroken - theIndex.novelIndex["7a992350f3eb6"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8 + theIndex._novelIndex["7a992350f3eb6"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8 theIndex.checkIndex() assert theIndex.indexBroken assert theIndex.loadIndex() assert not theIndex.indexBroken - theIndex.noteIndex["4c4f28287af27"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8 + theIndex._noteIndex["4c4f28287af27"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8 theIndex.checkIndex() assert theIndex.indexBroken assert theIndex.loadIndex() assert not theIndex.indexBroken - theIndex.textCounts["7a992350f3eb6"].append("Stuff") # No longer len() == 3 + theIndex._textCounts["7a992350f3eb6"].append("Stuff") # No longer len() == 3 theIndex.checkIndex() assert theIndex.indexBroken # Make the try/except trigger as well assert theIndex.loadIndex() assert not theIndex.indexBroken - theIndex.refIndex["fb609cd8319dc"]["T000001"] = {"tagssss": []} # Wrong key name + theIndex._refIndex["fb609cd8319dc"]["T000001"] = {"tagssss": []} # Wrong key name theIndex.checkIndex() assert theIndex.indexBroken @@ -217,7 +217,7 @@ def testCoreIndex_CheckThese(nwMinimal, dummyGUI): "# Hello World!\n" "@pov: Jane" )) - assert theIndex.tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} + assert theIndex._tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!" assert theIndex.novelChangedSince(0) @@ -293,7 +293,7 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): "This is a story about Jane Smith.\n\n" "Well, not really.\n" )) - assert str(theIndex.tagIndex) == "{'Jane': [2, '%s', 'CHARACTER', 'T000001']}" % cHandle + assert str(theIndex._tagIndex) == "{'Jane': [2, '%s', 'CHARACTER', 'T000001']}" % cHandle assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!" # Check that title sections are indexed properly @@ -313,68 +313,68 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): "##### Title Five\n\n" # Not interpreted as a title, the hashes is counted as a word "Paragraph Five.\n\n" )) - assert theIndex.refIndex[nHandle].get("T000000", None) is not None # Always there - assert theIndex.refIndex[nHandle].get("T000001", None) is not None # Heading 1 - assert theIndex.refIndex[nHandle].get("T000002", None) is None - assert theIndex.refIndex[nHandle].get("T000003", None) is None - assert theIndex.refIndex[nHandle].get("T000004", None) is None - assert theIndex.refIndex[nHandle].get("T000005", None) is None - assert theIndex.refIndex[nHandle].get("T000006", None) is None - assert theIndex.refIndex[nHandle].get("T000007", None) is not None # Heading 2 - assert theIndex.refIndex[nHandle].get("T000008", None) is None - assert theIndex.refIndex[nHandle].get("T000009", None) is None - assert theIndex.refIndex[nHandle].get("T000010", None) is None - assert theIndex.refIndex[nHandle].get("T000011", None) is None - assert theIndex.refIndex[nHandle].get("T000012", None) is None - assert theIndex.refIndex[nHandle].get("T000013", None) is not None # Heading 3 - assert theIndex.refIndex[nHandle].get("T000014", None) is None - assert theIndex.refIndex[nHandle].get("T000015", None) is None - assert theIndex.refIndex[nHandle].get("T000016", None) is None - assert theIndex.refIndex[nHandle].get("T000017", None) is None - assert theIndex.refIndex[nHandle].get("T000018", None) is None - assert theIndex.refIndex[nHandle].get("T000019", None) is not None # Heading 4 - assert theIndex.refIndex[nHandle].get("T000020", None) is None - assert theIndex.refIndex[nHandle].get("T000021", None) is None - assert theIndex.refIndex[nHandle].get("T000022", None) is None - assert theIndex.refIndex[nHandle].get("T000023", None) is None - assert theIndex.refIndex[nHandle].get("T000024", None) is None - assert theIndex.refIndex[nHandle].get("T000025", None) is None - assert theIndex.refIndex[nHandle].get("T000026", None) is None + assert theIndex._refIndex[nHandle].get("T000000", None) is not None # Always there + assert theIndex._refIndex[nHandle].get("T000001", None) is not None # Heading 1 + assert theIndex._refIndex[nHandle].get("T000002", None) is None + assert theIndex._refIndex[nHandle].get("T000003", None) is None + assert theIndex._refIndex[nHandle].get("T000004", None) is None + assert theIndex._refIndex[nHandle].get("T000005", None) is None + assert theIndex._refIndex[nHandle].get("T000006", None) is None + assert theIndex._refIndex[nHandle].get("T000007", None) is not None # Heading 2 + assert theIndex._refIndex[nHandle].get("T000008", None) is None + assert theIndex._refIndex[nHandle].get("T000009", None) is None + assert theIndex._refIndex[nHandle].get("T000010", None) is None + assert theIndex._refIndex[nHandle].get("T000011", None) is None + assert theIndex._refIndex[nHandle].get("T000012", None) is None + assert theIndex._refIndex[nHandle].get("T000013", None) is not None # Heading 3 + assert theIndex._refIndex[nHandle].get("T000014", None) is None + assert theIndex._refIndex[nHandle].get("T000015", None) is None + assert theIndex._refIndex[nHandle].get("T000016", None) is None + assert theIndex._refIndex[nHandle].get("T000017", None) is None + assert theIndex._refIndex[nHandle].get("T000018", None) is None + assert theIndex._refIndex[nHandle].get("T000019", None) is not None # Heading 4 + assert theIndex._refIndex[nHandle].get("T000020", None) is None + assert theIndex._refIndex[nHandle].get("T000021", None) is None + assert theIndex._refIndex[nHandle].get("T000022", None) is None + assert theIndex._refIndex[nHandle].get("T000023", None) is None + assert theIndex._refIndex[nHandle].get("T000024", None) is None + assert theIndex._refIndex[nHandle].get("T000025", None) is None + assert theIndex._refIndex[nHandle].get("T000026", None) is None - assert theIndex.novelIndex[nHandle]["T000001"]["level"] == "H1" - assert theIndex.novelIndex[nHandle]["T000007"]["level"] == "H2" - assert theIndex.novelIndex[nHandle]["T000013"]["level"] == "H3" - assert theIndex.novelIndex[nHandle]["T000019"]["level"] == "H4" + assert theIndex._novelIndex[nHandle]["T000001"]["level"] == "H1" + assert theIndex._novelIndex[nHandle]["T000007"]["level"] == "H2" + assert theIndex._novelIndex[nHandle]["T000013"]["level"] == "H3" + assert theIndex._novelIndex[nHandle]["T000019"]["level"] == "H4" - assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Title One" - assert theIndex.novelIndex[nHandle]["T000007"]["title"] == "Title Two" - assert theIndex.novelIndex[nHandle]["T000013"]["title"] == "Title Three" - assert theIndex.novelIndex[nHandle]["T000019"]["title"] == "Title Four" + assert theIndex._novelIndex[nHandle]["T000001"]["title"] == "Title One" + assert theIndex._novelIndex[nHandle]["T000007"]["title"] == "Title Two" + assert theIndex._novelIndex[nHandle]["T000013"]["title"] == "Title Three" + assert theIndex._novelIndex[nHandle]["T000019"]["title"] == "Title Four" - assert theIndex.novelIndex[nHandle]["T000001"]["layout"] == "SCENE" - assert theIndex.novelIndex[nHandle]["T000007"]["layout"] == "SCENE" - assert theIndex.novelIndex[nHandle]["T000013"]["layout"] == "SCENE" - assert theIndex.novelIndex[nHandle]["T000019"]["layout"] == "SCENE" + assert theIndex._novelIndex[nHandle]["T000001"]["layout"] == "SCENE" + assert theIndex._novelIndex[nHandle]["T000007"]["layout"] == "SCENE" + assert theIndex._novelIndex[nHandle]["T000013"]["layout"] == "SCENE" + assert theIndex._novelIndex[nHandle]["T000019"]["layout"] == "SCENE" - assert theIndex.novelIndex[nHandle]["T000001"]["synopsis"] == "Synopsis One." - assert theIndex.novelIndex[nHandle]["T000007"]["synopsis"] == "Synopsis Two." - assert theIndex.novelIndex[nHandle]["T000013"]["synopsis"] == "Synopsis Three." - assert theIndex.novelIndex[nHandle]["T000019"]["synopsis"] == "Synopsis Four." + assert theIndex._novelIndex[nHandle]["T000001"]["synopsis"] == "Synopsis One." + assert theIndex._novelIndex[nHandle]["T000007"]["synopsis"] == "Synopsis Two." + assert theIndex._novelIndex[nHandle]["T000013"]["synopsis"] == "Synopsis Three." + assert theIndex._novelIndex[nHandle]["T000019"]["synopsis"] == "Synopsis Four." - assert theIndex.novelIndex[nHandle]["T000001"]["cCount"] == 23 - assert theIndex.novelIndex[nHandle]["T000007"]["cCount"] == 23 - assert theIndex.novelIndex[nHandle]["T000013"]["cCount"] == 27 - assert theIndex.novelIndex[nHandle]["T000019"]["cCount"] == 56 + assert theIndex._novelIndex[nHandle]["T000001"]["cCount"] == 23 + assert theIndex._novelIndex[nHandle]["T000007"]["cCount"] == 23 + assert theIndex._novelIndex[nHandle]["T000013"]["cCount"] == 27 + assert theIndex._novelIndex[nHandle]["T000019"]["cCount"] == 56 - assert theIndex.novelIndex[nHandle]["T000001"]["wCount"] == 4 - assert theIndex.novelIndex[nHandle]["T000007"]["wCount"] == 4 - assert theIndex.novelIndex[nHandle]["T000013"]["wCount"] == 4 - assert theIndex.novelIndex[nHandle]["T000019"]["wCount"] == 9 + assert theIndex._novelIndex[nHandle]["T000001"]["wCount"] == 4 + assert theIndex._novelIndex[nHandle]["T000007"]["wCount"] == 4 + assert theIndex._novelIndex[nHandle]["T000013"]["wCount"] == 4 + assert theIndex._novelIndex[nHandle]["T000019"]["wCount"] == 9 - assert theIndex.novelIndex[nHandle]["T000001"]["pCount"] == 1 - assert theIndex.novelIndex[nHandle]["T000007"]["pCount"] == 1 - assert theIndex.novelIndex[nHandle]["T000013"]["pCount"] == 1 - assert theIndex.novelIndex[nHandle]["T000019"]["pCount"] == 3 + assert theIndex._novelIndex[nHandle]["T000001"]["pCount"] == 1 + assert theIndex._novelIndex[nHandle]["T000007"]["pCount"] == 1 + assert theIndex._novelIndex[nHandle]["T000013"]["pCount"] == 1 + assert theIndex._novelIndex[nHandle]["T000019"]["pCount"] == 3 assert theIndex.scanText(cHandle, ( "# Title One\n\n" @@ -382,22 +382,22 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): "% synopsis: Synopsis One.\n\n" "Paragraph One.\n\n" )) - assert theIndex.refIndex[cHandle].get("T000000", None) is not None - assert theIndex.refIndex[cHandle].get("T000001", None) is not None - assert theIndex.refIndex[cHandle].get("T000002", None) is None - assert theIndex.refIndex[cHandle].get("T000003", None) is None - assert theIndex.refIndex[cHandle].get("T000004", None) is None - assert theIndex.refIndex[cHandle].get("T000005", None) is None - assert theIndex.refIndex[cHandle].get("T000006", None) is None - assert theIndex.refIndex[cHandle].get("T000007", None) is None + assert theIndex._refIndex[cHandle].get("T000000", None) is not None + assert theIndex._refIndex[cHandle].get("T000001", None) is not None + assert theIndex._refIndex[cHandle].get("T000002", None) is None + assert theIndex._refIndex[cHandle].get("T000003", None) is None + assert theIndex._refIndex[cHandle].get("T000004", None) is None + assert theIndex._refIndex[cHandle].get("T000005", None) is None + assert theIndex._refIndex[cHandle].get("T000006", None) is None + assert theIndex._refIndex[cHandle].get("T000007", None) is None - assert theIndex.noteIndex[cHandle]["T000001"]["level"] == "H1" - assert theIndex.noteIndex[cHandle]["T000001"]["title"] == "Title One" - assert theIndex.noteIndex[cHandle]["T000001"]["layout"] == "NOTE" - assert theIndex.noteIndex[cHandle]["T000001"]["synopsis"] == "Synopsis One." - assert theIndex.noteIndex[cHandle]["T000001"]["cCount"] == 23 - assert theIndex.noteIndex[cHandle]["T000001"]["wCount"] == 4 - assert theIndex.noteIndex[cHandle]["T000001"]["pCount"] == 1 + assert theIndex._noteIndex[cHandle]["T000001"]["level"] == "H1" + assert theIndex._noteIndex[cHandle]["T000001"]["title"] == "Title One" + assert theIndex._noteIndex[cHandle]["T000001"]["layout"] == "NOTE" + assert theIndex._noteIndex[cHandle]["T000001"]["synopsis"] == "Synopsis One." + assert theIndex._noteIndex[cHandle]["T000001"]["cCount"] == 23 + assert theIndex._noteIndex[cHandle]["T000001"]["wCount"] == 4 + assert theIndex._noteIndex[cHandle]["T000001"]["pCount"] == 1 assert theIndex.scanText(sHandle, ( "# Title One\n\n" @@ -407,7 +407,7 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): "% synopsis: Synopsis One.\n\n" "Paragraph One.\n\n" )) - assert theIndex.refIndex[sHandle]["T000001"]["tags"] == ( + assert theIndex._refIndex[sHandle]["T000001"]["tags"] == ( [[3, "@pov", "One"], [5, "@char", "Two"]] ) diff --git a/tests/test_gui_docviewer.py b/tests/test_gui_docviewer.py index 3af74afa..235a4c30 100644 --- a/tests/test_gui_docviewer.py +++ b/tests/test_gui_docviewer.py @@ -26,11 +26,11 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): assert nwGUI.openProject(nwLipsum) # Rebuild the index as it isn't automatically copied - assert nwGUI.theIndex.tagIndex == {} - assert nwGUI.theIndex.refIndex == {} + assert nwGUI.theIndex._tagIndex == {} + assert nwGUI.theIndex._refIndex == {} nwGUI.mainMenu.aRebuildIndex.activate(QAction.Trigger) - assert nwGUI.theIndex.tagIndex != {} - assert nwGUI.theIndex.refIndex != {} + assert nwGUI.theIndex._tagIndex != {} + assert nwGUI.theIndex._refIndex != {} # Select a document in the project tree assert nwGUI.treeView.setSelectedHandle("88243afbe5ed8") From 0b7e4d594ed4134baba549659be308f1ddff7ac8 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 2 Jan 2021 22:34:52 +0100 Subject: [PATCH 14/15] Fix a minor issue in the outline details class --- nw/gui/outlinedetails.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nw/gui/outlinedetails.py b/nw/gui/outlinedetails.py index 5e1d1105..ea9ec9f4 100644 --- a/nw/gui/outlinedetails.py +++ b/nw/gui/outlinedetails.py @@ -274,7 +274,7 @@ class GuiOutlineDetails(QScrollArea): nwItem = self.theProject.projTree[tHandle] novIdx = self.theIndex.getNovelData(tHandle, sTitle) theRefs = self.theIndex.getReferences(tHandle, sTitle) - if nwItem is None or novIdx is None or theRefs == {}: + if nwItem is None or novIdx is None: return False if novIdx["level"] in self.LVL_MAP: @@ -328,7 +328,7 @@ class GuiOutlineDetails(QScrollArea): def _formatTags(self, theRefs, theKey): """Format the tags as clickable links. """ - if theKey not in theKey: + if theKey not in theRefs: return "" refTags = [] for tTag in theRefs[theKey]: From 5efc61b155a5516930215a9bebbf6cabd35ae479 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 3 Jan 2021 17:21:06 +0100 Subject: [PATCH 15/15] Updated changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41144367..c22116af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Version 1.1 Dev (Alpha) +### Release Notes + +### Detailed Changelog + +**User Interface** + +* Added a Novel tab under the project tree where the user can navigate the novel's layout of + chapters and scenes, similar to the Outline view, but next to the document editor. The Outline + view and Novel/Project trees now also behave more in cooperation. When files on one are selected + or moved, the other will follow and update. PR #537. + ---- ## Version 1.0 [2021-01-03]