diff --git a/docs/source/usage_shortcuts.rst b/docs/source/usage_shortcuts.rst index fea65c8c..69c2e773 100644 --- a/docs/source/usage_shortcuts.rst +++ b/docs/source/usage_shortcuts.rst @@ -59,7 +59,6 @@ The main shorcuts are as follows: ":kbd:`Ctrl`:kbd:`Y`", "Redo latest undo." ":kbd:`Ctrl`:kbd:`Z`", "Undo latest changes." ":kbd:`Ctrl`:kbd:`F7`", "Toggle spell checking." - ":kbd:`Ctrl`:kbd:`F10`", "Toggle automatic updating of project outline." ":kbd:`Ctrl`:kbd:`Up`", "Move item one step up in the project tree." ":kbd:`Ctrl`:kbd:`Down`", "Move item one step down in the project tree." ":kbd:`Ctrl`:kbd:`Del`", "Delete next word in editor." @@ -88,7 +87,6 @@ The main shorcuts are as follows: ":kbd:`F7`", "Re-run spell checker." ":kbd:`F8`", "Activate :guilabel:`Focus Mode`, hiding the project tree and document viewer." ":kbd:`F9`", "Re-build the project index." - ":kbd:`F10`", "Re-build the project outline." ":kbd:`F11`", "Activate full screen mode." ":kbd:`Shift`:kbd:`F1`", "Open the local user manual (PDF) if it is available." ":kbd:`Shift`:kbd:`F3`", "Find previous occurrence of search word in current document." diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index cde02134..ae157e2c 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -47,6 +47,7 @@ edit = typ_pencil.svg forward = typ_chevron-right.svg hash = typ_hash.svg maximise = typ_arrow-maximise.svg +menu = typ_th-menu.svg minimise = typ_arrow-minimise.svg proj_chapter = mixed_document-chapter.svg proj_details = typ_th-list-grey.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_th-menu.svg b/novelwriter/assets/icons/typicons_dark/typ_th-menu.svg new file mode 100644 index 00000000..89434cdf --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_th-menu.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 85941827..4683bb19 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -47,6 +47,7 @@ edit = typ_pencil.svg forward = typ_chevron-right.svg hash = typ_hash.svg maximise = typ_arrow-maximise.svg +menu = typ_th-menu.svg minimise = typ_arrow-minimise.svg proj_chapter = mixed_document-chapter.svg proj_details = typ_th-list-grey.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_th-menu.svg b/novelwriter/assets/icons/typicons_light/typ_th-menu.svg new file mode 100644 index 00000000..cfc8a1d9 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_th-menu.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 76a7ec55..f86de8c2 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -73,9 +73,8 @@ class NWIndex: self._indexBroken = False # TimeStamps - self._timeNovel = 0 - self._timeNotes = 0 - self._timeIndex = 0 + self._indexChange = 0 + self._rootChange = {} return @@ -99,9 +98,8 @@ class NWIndex: """ self._tagsIndex.clear() self._itemIndex.clear() - self._timeNovel = 0 - self._timeNotes = 0 - self._timeIndex = 0 + self._indexChange = 0 + self._rootChange = {} return def deleteHandle(self, tHandle): @@ -129,20 +127,16 @@ 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 + return self._indexChange > checkTime + + def rootChangedSince(self, rootHandle, checkTime): + """Check if the index has changed since a given time for a + given root item. + """ + return self._rootChange.get(rootHandle, self._indexChange) > checkTime ## # Load and Save Index to/from File @@ -184,10 +178,7 @@ class NWIndex: logger.warning("Item '%s' is not in the index", fHandle) self.reIndexHandle(fHandle) - nowTime = round(time()) - self._timeNovel = nowTime - self._timeNotes = nowTime - self._timeIndex = nowTime + self._indexChange = round(time()) logger.verbose("Index loaded in %.3f ms", (time() - tStart)*1000) @@ -307,11 +298,8 @@ class NWIndex: # Update timestamps for index changes nowTime = round(time()) - self._timeIndex = nowTime - if theItem.itemLayout == nwItemLayout.NOTE: - self._timeNotes = nowTime - else: - self._timeNovel = nowTime + self._indexChange = nowTime + self._rootChange[theItem.itemRoot] = nowTime return True @@ -466,14 +454,14 @@ class NWIndex: # Extract Data ## - def novelStructure(self, skipExcl=True): + def novelStructure(self, rootHandle=None, skipExcl=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. """ - for tHandle, sTitle, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl): - tKey = f"{tHandle}:{sTitle}" - yield tKey, tHandle, sTitle, hItem + novStruct = self._itemIndex.iterNovelStructure(rootHandle=rootHandle, skipExcl=skipExcl) + for tHandle, sTitle, hItem in novStruct: + yield f"{tHandle}:{sTitle}", tHandle, sTitle, hItem return def getNovelWordCount(self, skipExcl=True): @@ -514,6 +502,11 @@ class NWIndex: """ return self._itemIndex.mainItemHeader(tHandle) + def getHandleHeaderIntLevel(self, tHandle): + """Get the integer header level of the first header of a handle. + """ + return H_LEVEL.get(self._itemIndex.mainItemHeader(tHandle), 0) + def getTableOfContents(self, maxDepth, skipExcl=True): """Generate a table of contents up to a maximum depth. """ diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index ee57e0db..10a7a78f 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -265,6 +265,22 @@ class NWTree(): # Tree Root Methods ## + def rootClasses(self): + """Return a set of all root classes in use by the project. + """ + rootClasses = set() + for nwItem in self._treeRoots.values(): + rootClasses.add(nwItem.itemClass) + return rootClasses + + def iterRoots(self, itemClass): + """Iterate over all items of a given class. + """ + for tHandle, nwItem in self._treeRoots.items(): + if nwItem.itemClass == itemClass: + yield tHandle, nwItem + return + def isRoot(self, tHandle): """Check if a handle is a root item. """ diff --git a/novelwriter/enum.py b/novelwriter/enum.py index fc9604e0..48b894ba 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -62,6 +62,14 @@ class nwItemLayout(Enum): # END Enum nwItemLayout +class nwDocMode(Enum): + + VIEW = 0 + EDIT = 1 + +# END Enum nwDocMode + + class nwDocAction(Enum): NO_ACTION = 0 diff --git a/novelwriter/gui/__init__.py b/novelwriter/gui/__init__.py index 8364e342..e3560a99 100644 --- a/novelwriter/gui/__init__.py +++ b/novelwriter/gui/__init__.py @@ -25,7 +25,6 @@ from novelwriter.gui.itemdetails import GuiItemDetails from novelwriter.gui.mainmenu import GuiMainMenu from novelwriter.gui.noveltree import GuiNovelTree from novelwriter.gui.outline import GuiOutline -from novelwriter.gui.outlinedetails import GuiOutlineDetails from novelwriter.gui.projtree import GuiProjectTree from novelwriter.gui.statusbar import GuiMainStatus from novelwriter.gui.theme import GuiTheme @@ -40,7 +39,6 @@ __all__ = [ "GuiMainStatus", "GuiNovelTree", "GuiOutline", - "GuiOutlineDetails", "GuiProjectTree", "GuiTheme", "GuiViewsBar", diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 64e72259..5ede17f1 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -33,6 +33,7 @@ import bisect import logging import novelwriter +from enum import Enum from time import time from PyQt5.QtCore import ( @@ -50,7 +51,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.core import NWDoc, NWSpellEnchant, countWords -from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert +from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode from novelwriter.common import transferCase from novelwriter.constants import nwConst, nwKeyWords, nwUnicode from novelwriter.gui.dochighlight import GuiDocHighlighter @@ -69,6 +70,7 @@ class GuiDocEditor(QTextEdit): spellDictionaryChanged = pyqtSignal(str, str) docEditedStatusChanged = pyqtSignal(bool) docCountsChanged = pyqtSignal(str, int, int, int) + loadDocumentTagRequest = pyqtSignal(str, Enum) def __init__(self, theParent): QTextEdit.__init__(self, theParent) @@ -567,16 +569,6 @@ class GuiDocEditor(QTextEdit): return - def updateDocInfo(self, tHandle): - """Called when an item label is changed to check if the document - title bar needs updating, - """ - if tHandle == self._docHandle: - self.docHeader.setTitleFromHandle(self._docHandle) - self.docFooter.updateInfo() - self.updateDocMargins() - return - ## # Properties ## @@ -1066,7 +1058,22 @@ class GuiDocEditor(QTextEdit): return ## - # Slots + # Public Slots + ## + + @pyqtSlot(str) + def updateDocInfo(self, tHandle): + """Called when an item label is changed to check if the document + title bar needs updating, + """ + if tHandle == self._docHandle: + self.docHeader.setTitleFromHandle(self._docHandle) + self.docFooter.updateInfo() + self.updateDocMargins() + return + + ## + # Private Slots ## @pyqtSlot(int, int, int) @@ -1894,7 +1901,7 @@ class GuiDocEditor(QTextEdit): if loadTag: logger.verbose("Attempting to follow tag '%s'", theWord) - self.theParent.docViewer.loadFromTag(theWord) + self.loadDocumentTagRequest.emit(theWord, nwDocMode.VIEW) else: logger.verbose("Potential tag '%s'", theWord) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 2b59cf8e..89ae8ea2 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -30,7 +30,9 @@ along with this program. If not, see . import logging import novelwriter -from PyQt5.QtCore import Qt, QUrl, QSize, pyqtSlot +from enum import Enum + +from PyQt5.QtCore import Qt, QUrl, QSize, pyqtSlot, pyqtSignal from PyQt5.QtGui import ( QTextOption, QFont, QPalette, QColor, QTextCursor, QIcon, QCursor ) @@ -40,7 +42,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.core import ToHtml -from novelwriter.enum import nwAlert, nwItemType, nwDocAction +from novelwriter.enum import nwItemType, nwDocAction, nwDocMode from novelwriter.error import logException from novelwriter.constants import nwUnicode @@ -49,6 +51,8 @@ logger = logging.getLogger(__name__) class GuiDocViewer(QTextBrowser): + loadDocumentTagRequest = pyqtSignal(str, Enum) + def __init__(self, theParent): QTextBrowser.__init__(self, theParent) @@ -239,30 +243,6 @@ class GuiDocViewer(QTextBrowser): self.updateDocMargins() return - def loadFromTag(self, theTag): - """Load text in the document from a reference given by a meta - tag rather than a known handle. This function depends on the - index being up to date. - """ - logger.debug("Loading document from tag '%s'", theTag) - tHandle, sTitle = self.theProject.index.getTagSource(theTag) - if tHandle is None: - self.theParent.makeAlert(self.tr( - "Could not find the reference for tag '{0}'. It either doesn't " - "exist, or the index is out of date. The index can be updated " - "from the Tools menu, or by pressing {1}." - ).format( - theTag, "F9" - ), nwAlert.ERROR) - return False - else: - # Let the parent handle the opening as it also ensures that - # the doc view panel is visible in case this request comes - # from outside this class. - logger.verbose("Tag points to '%s#%s'", tHandle, sTitle) - self.theParent.viewDocument(tHandle, "#%s" % sTitle) - return True - def docAction(self, theAction): """Wrapper function for various document actions on the current document. @@ -342,15 +322,6 @@ class GuiDocViewer(QTextBrowser): return - def updateDocInfo(self, tHandle): - """Called when an item label is changed to check if the document - title bar needs updating, - """ - if tHandle == self._docHandle: - self.docHeader.setTitleFromHandle(self._docHandle) - self.updateDocMargins() - return - ## # Properties ## @@ -409,19 +380,33 @@ class GuiDocViewer(QTextBrowser): return 0 ## - # Slots + # Public Slots + ## + + @pyqtSlot(str) + def updateDocInfo(self, tHandle): + """Called when an item label is changed to check if the document + title bar needs updating, + """ + if tHandle == self._docHandle: + self.docHeader.setTitleFromHandle(self._docHandle) + self.updateDocMargins() + return + + ## + # Private Slots ## @pyqtSlot("QUrl") def _linkClicked(self, theURL): - """Slot for a link in the document being clicked. + """Process a clicked link internally in the document. """ theLink = theURL.url() logger.verbose("Clicked link: '%s'", theLink) if len(theLink) > 0: theBits = theLink.split("=") if len(theBits) == 2: - self.loadFromTag(theBits[1]) + self.loadDocumentTagRequest.emit(theBits[1], nwDocMode.VIEW) return @pyqtSlot("QPoint") diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py index 89a38b76..643479a4 100644 --- a/novelwriter/gui/itemdetails.py +++ b/novelwriter/gui/itemdetails.py @@ -220,6 +220,11 @@ class GuiItemDetails(QWidget): """ self.updateViewBox(self._itemHandle) + ## + # Public Slots + ## + + @pyqtSlot(str) def updateViewBox(self, tHandle): """Populate the details box from a given handle. """ @@ -290,10 +295,6 @@ class GuiItemDetails(QWidget): return - ## - # Slots - ## - @pyqtSlot(str, int, int, int) def doUpdateCounts(self, tHandle, cC, wC, pC): """Update the counts if the handle is the same as the one we're diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index f69d5a56..bd8d1edd 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -81,12 +81,6 @@ class GuiMainMenu(QMenuBar): self.aSpellCheck.setChecked(theMode) return - def setAutoOutline(self, theMode): - """Forward auto outline check state to its action. - """ - self.aAutoOutline.setChecked(theMode) - return - def setFocusMode(self, theMode): """Forward focus mode check state to its action. """ @@ -105,12 +99,6 @@ class GuiMainMenu(QMenuBar): self.theParent.docEditor.toggleSpellCheck(None) return True - def _toggleAutoOutline(self, theMode): - """Toggle auto outline when the menu entry is checked. - """ - self.theProject.setAutoOutline(theMode) - return True - def _openWebsite(self, theUrl): """Open a URL in the system's default browser. """ @@ -889,19 +877,6 @@ class GuiMainMenu(QMenuBar): self.aRebuildIndex.triggered.connect(lambda: self.theParent.rebuildIndex()) self.toolsMenu.addAction(self.aRebuildIndex) - # Tools > Rebuild Outline - self.aRebuildOutline = QAction(self.tr("Rebuild Outline"), self) - self.aRebuildOutline.setShortcut("F10") - self.aRebuildOutline.triggered.connect(lambda: self.theParent.rebuildOutline()) - self.toolsMenu.addAction(self.aRebuildOutline) - - # Tools > Toggle Auto Build Outline - self.aAutoOutline = QAction(self.tr("Auto-Update Outline"), self) - self.aAutoOutline.setCheckable(True) - self.aAutoOutline.toggled.connect(self._toggleAutoOutline) - self.aAutoOutline.setShortcut("Ctrl+F10") - self.toolsMenu.addAction(self.aAutoOutline) - # Tools > Separator self.toolsMenu.addSeparator() diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index a1cd11d3..9ac2ea2b 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -136,7 +136,7 @@ class GuiNovelTree(QTreeWidget): """ logger.verbose("Requesting refresh of the novel tree") treeChanged = self.theParent.treeView.changedSince(self._lastBuild) - indexChanged = self.theProject.index.novelChangedSince(self._lastBuild) + indexChanged = self.theProject.index.indexChangedSince(self._lastBuild) if not (treeChanged or indexChanged or overRide): logger.verbose("No changes have been made to the novel index") return @@ -146,7 +146,6 @@ 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: diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 83b04f47..34cb35da 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -4,7 +4,11 @@ novelWriter – GUI Project Outline GUI class for the project outline view File History: -Created: 2019-11-16 [0.4.1] +Created: 2022-05-15 [1.7b1] GuiOutline +Created: 2022-05-22 [1.7b1] GuiOutlineToolBar +Created: 2019-11-16 [0.4.1] GuiOutlineView +Created: 2019-11-16 [0.4.1] GuiOutlineHeaderMenu +Created: 2020-06-02 [0.7.0] GuiOutlineDetails This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -27,20 +31,244 @@ import logging import novelwriter from time import time +from enum import Enum -from PyQt5.QtCore import Qt, QSize, pyqtSlot +from PyQt5.QtCore import ( + Qt, pyqtSignal, pyqtSlot, QSize, QT_TRANSLATE_NOOP +) from PyQt5.QtWidgets import ( - QTreeWidget, QTreeWidgetItem, QMenu, QAction, QAbstractItemView, QFrame + QAbstractItemView, QAction, QGridLayout, QGroupBox, QHBoxLayout, QLabel, + QMenu, QScrollArea, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget, QFrame, QToolBar, QSizePolicy, QComboBox, QToolButton ) -from novelwriter.enum import nwItemLayout, nwItemType, nwOutline +from novelwriter.enum import ( + nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline +) from novelwriter.common import checkInt from novelwriter.constants import trConst, nwKeyWords, nwLabels + logger = logging.getLogger(__name__) -class GuiOutline(QTreeWidget): +class GuiOutline(QWidget): + + loadDocumentTagRequest = pyqtSignal(str, Enum) + + def __init__(self, theParent): + QWidget.__init__(self, theParent) + + self.mainConf = novelwriter.CONFIG + self.theParent = theParent + + self.outlineBar = GuiOutlineToolBar(self) + self.outlineView = GuiOutlineView(self) + self.outlineData = GuiOutlineDetails(self) + + self.splitOutline = QSplitter(Qt.Vertical) + self.splitOutline.addWidget(self.outlineView) + self.splitOutline.addWidget(self.outlineData) + self.splitOutline.setSizes(self.mainConf.getOutlinePanePos()) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox.addWidget(self.outlineBar) + self.outerBox.addWidget(self.splitOutline) + + self.setLayout(self.outerBox) + + # Connect Signals + self.outlineView.hiddenStateChanged.connect(self._updateMenuColumns) + self.outlineView.activeItemChanged.connect(self.outlineData.showItem) + self.outlineData.itemTagClicked.connect(self._tagClicked) + self.outlineBar.loadNovelRootRequest.connect(self._rootItemChanged) + self.outlineBar.viewColumnToggled.connect(self.outlineView.menuColumnToggled) + + # Function Mappings + self.getSelectedHandle = self.outlineView.getSelectedHandle + + return + + ## + # Methods + ## + + def splitSizes(self): + return self.splitOutline.sizes() + + def clearOutline(self): + self.outlineData.clearDetails() + return + + def initOutline(self): + self.outlineView.initOutline() + self.outlineData.initDetails() + return + + def closeOutline(self): + self.outlineView.closeOutline() + self.outlineData.updateClasses() + return + + def refreshView(self, overRide=False, novelChanged=False): + self.outlineView.refreshTree(overRide=overRide, novelChanged=novelChanged) + return + + def treeFocus(self): + return self.outlineView.hasFocus() + + def setTreeFocus(self): + return self.outlineView.setFocus() + + ## + # Public Slots + ## + + @pyqtSlot(str) + def updateRootItem(self, tHandle): + """Should be called whenever a root folders changes. + """ + self.outlineBar.populateNovelList() + self.outlineData.updateClasses() + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _updateMenuColumns(self): + """Trigger an update of the toggled state of the column menu + checkboxes whenever a signal is received that the hidden state + of columns has changed. + """ + self.outlineBar.setColumnHiddenState(self.outlineView.hiddenColumns) + return + + @pyqtSlot(str) + def _tagClicked(self, link): + """Capture the click of a tag in the details panel. + """ + if link: + self.loadDocumentTagRequest.emit(link, nwDocMode.VIEW) + return + + @pyqtSlot(str) + def _rootItemChanged(self, handle): + """The root novel handle has changed or needs to be refreshed. + """ + self.outlineView.refreshTree(rootHandle=(handle or None), overRide=True) + return + +# END Class GuiOutline + + +class GuiOutlineToolBar(QToolBar): + + loadNovelRootRequest = pyqtSignal(str) + viewColumnToggled = pyqtSignal(bool, Enum) + + def __init__(self, theOutline): + QTreeWidget.__init__(self, theOutline) + + logger.debug("Initialising GuiOutlineToolBar ...") + + self.mainConf = novelwriter.CONFIG + self.theParent = theOutline.theParent + self.theProject = theOutline.theParent.theProject + self.theTheme = theOutline.theParent.theTheme + + iPx = self.mainConf.pxInt(22) + mPx = self.mainConf.pxInt(12) + + self.setMovable(False) + self.setIconSize(QSize(iPx, iPx)) + self.setContentsMargins(0, 0, 0, 0) + self.setStyleSheet("QToolBar {border: 0px;}") + + stretch = QWidget(self) + stretch.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Novel Selector + self.novelLabel = QLabel(self.tr("Outline of")) + self.novelLabel.setContentsMargins(0, 0, mPx, 0) + + self.novelValue = QComboBox(self) + self.novelValue.setMinimumWidth(self.mainConf.pxInt(200)) + self.novelValue.currentIndexChanged.connect(self._novelValueChanged) + + # Actions + self.aRefresh = QAction(self.tr("Refresh"), self) + self.aRefresh.setIcon(self.theTheme.getIcon("refresh")) + self.aRefresh.triggered.connect(self._refreshRequested) + + # Column Menu + self.mColumns = GuiOutlineHeaderMenu(self) + self.mColumns.columnToggled.connect( + lambda isChecked, tItem: self.viewColumnToggled.emit(isChecked, tItem) + ) + + self.tbColumns = QToolButton(self) + self.tbColumns.setIcon(self.theTheme.getIcon("menu")) + self.tbColumns.setMenu(self.mColumns) + self.tbColumns.setPopupMode(QToolButton.InstantPopup) + + # Assemble + self.addWidget(self.novelLabel) + self.addWidget(self.novelValue) + self.addSeparator() + self.addAction(self.aRefresh) + self.addWidget(self.tbColumns) + self.addWidget(stretch) + + logger.debug("GuiOutlineToolBar initialisation complete") + + ## + # Methods + ## + + def populateNovelList(self): + """Fill the novel combo box with a list of all novel folders. + """ + self.novelValue.clear() + tIcon = self.theTheme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL]) + for tHandle, nwItem in self.theProject.tree.iterRoots(nwItemClass.NOVEL): + self.novelValue.addItem(tIcon, nwItem.itemName, tHandle) + self.novelValue.insertSeparator(self.novelValue.count()) + self.novelValue.addItem(tIcon, self.tr("All Novel Folders"), "") + return + + def setColumnHiddenState(self, hiddenState): + """Forward the change of column hidden states to the menu. + """ + self.mColumns.setHiddenState(hiddenState) + return + + ## + # Private Slots + ## + + @pyqtSlot(int) + def _novelValueChanged(self, index): + """Emit a signal containing the handle of the selected item. + """ + if index >= 0: + self.loadNovelRootRequest.emit(self.novelValue.currentData()) + return + + @pyqtSlot() + def _refreshRequested(self): + """Emit a signal containing the handle of the selected item. + """ + self.loadNovelRootRequest.emit(self.novelValue.currentData()) + return + +# END Class GuiOutlineToolBar + + +class GuiOutlineView(QTreeWidget): DEF_WIDTH = { nwOutline.TITLE: 200, @@ -82,16 +310,18 @@ class GuiOutline(QTreeWidget): nwOutline.SYNOP: False, } - def __init__(self, theParent): - QTreeWidget.__init__(self, theParent) + hiddenStateChanged = pyqtSignal() + activeItemChanged = pyqtSignal(str, str) - logger.debug("Initialising GuiOutline ...") + def __init__(self, theOutline): + QTreeWidget.__init__(self, theOutline) + + logger.debug("Initialising GuiOutlineView ...") self.mainConf = novelwriter.CONFIG - self.theParent = theParent - self.theProject = theParent.theProject - self.theTheme = theParent.theTheme - self.headerMenu = GuiOutlineHeaderMenu(self) + self.theParent = theOutline.theParent + self.theProject = theOutline.theParent.theProject + self.theTheme = theOutline.theParent.theTheme self.setFrameStyle(QFrame.NoFrame) self.setSelectionBehavior(QAbstractItemView.SelectRows) @@ -106,8 +336,6 @@ class GuiOutline(QTreeWidget): self.setIndentation(iPx) self.treeHead = self.header() - self.treeHead.setContextMenuPolicy(Qt.CustomContextMenu) - self.treeHead.customContextMenuRequested.connect(self._headerRightClick) self.treeHead.sectionMoved.connect(self._columnMoved) # Internals @@ -121,12 +349,25 @@ class GuiOutline(QTreeWidget): self.initOutline() self.clearOutline() - self.headerMenu.setHiddenState(self._colHidden) - logger.debug("GuiOutline initialisation complete") + self.hiddenStateChanged.emit() + + logger.debug("GuiOutlineView initialisation complete") return + ## + # Properties + ## + + @property + def hiddenColumns(self): + return self._colHidden + + ## + # Methods + ## + def initOutline(self): """Set or update outline settings. """ @@ -166,7 +407,7 @@ class GuiOutline(QTreeWidget): return - def refreshTree(self, overRide=False, novelChanged=False): + def refreshTree(self, rootHandle=None, 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. @@ -174,17 +415,17 @@ class GuiOutline(QTreeWidget): # If it's the first time, we always build if self._firstView or self._firstView and overRide: self._loadHeaderState() - self._populateTree() + self._populateTree(rootHandle) self._firstView = False return # 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.theProject.index.novelChangedSince(self._lastBuild) + indexChanged = self.theProject.index.rootChangedSince(rootHandle, self._lastBuild) doBuild = (novelChanged or indexChanged) and self.theProject.autoOutline if doBuild or overRide: logger.debug("Rebuilding Project Outline") - self._populateTree() + self._populateTree(rootHandle) return @@ -220,7 +461,7 @@ class GuiOutline(QTreeWidget): document editor. """ tHandle, tLine = self.getSelectedHandle() - self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True) + self.theParent.openDocument(tHandle, tLine=tLine - 1, doScroll=True) return @pyqtSlot() @@ -232,18 +473,10 @@ class GuiOutline(QTreeWidget): if selItems: tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], Qt.UserRole) sTitle = selItems[0].data(self._colIdx[nwOutline.LINE], Qt.UserRole) - self.theParent.projMeta.showItem(tHandle, sTitle) - self.theParent.treeView.setSelectedHandle(tHandle) + self.activeItemChanged.emit(tHandle, sTitle) return - @pyqtSlot("QPoint") - def _headerRightClick(self, clickPos): - """Show the header column menu. - """ - self.headerMenu.exec_(self.mapToGlobal(clickPos)) - return - @pyqtSlot(int, int, int) def _columnMoved(self, logIdx, oldVisualIdx, newVisualIdx): """Make sure the order array is up to date with the actual order @@ -253,9 +486,10 @@ class GuiOutline(QTreeWidget): self._saveHeaderState() return - def _menuColumnToggled(self, isChecked, theItem): + @pyqtSlot(bool, Enum) + def menuColumnToggled(self, isChecked, theItem): """Receive the changes to column visibility forwarded by the - header context menu. + column selection menu. """ logger.verbose("User toggled Outline column '%s'", theItem.name) if theItem in self._colIdx: @@ -314,7 +548,7 @@ class GuiOutline(QTreeWidget): except Exception: logger.warning("Ignored unknown outline column '%s'", str(hName)) - self.headerMenu.setHiddenState(self._colHidden) + self.hiddenStateChanged.emit() return @@ -356,7 +590,7 @@ class GuiOutline(QTreeWidget): return - def _populateTree(self): + def _populateTree(self, rootHandle): """Build the tree based on the project index, and the header based on the defined constants, default values and user selected width, order and hidden state. All columns are populated, even @@ -389,7 +623,8 @@ class GuiOutline(QTreeWidget): currChapter = None currScene = None - for _, tHandle, sTitle, novIdx in self.theProject.index.novelStructure(skipExcl=True): + novStruct = self.theProject.index.novelStructure(rootHandle=rootHandle, skipExcl=True) + for _, tHandle, sTitle, novIdx in novStruct: tItem = self._createTreeItem(tHandle, sTitle, novIdx) @@ -479,15 +714,16 @@ class GuiOutline(QTreeWidget): return newItem -# END Class GuiOutline +# END Class GuiOutlineView class GuiOutlineHeaderMenu(QMenu): - def __init__(self, theParent): - QMenu.__init__(self, theParent) + columnToggled = pyqtSignal(bool, Enum) + + def __init__(self, theOutline): + QMenu.__init__(self, theOutline) - self.theParent = theParent self.acceptToggle = True mnuHead = QAction(self.tr("Select Columns"), self) @@ -501,7 +737,7 @@ class GuiOutlineHeaderMenu(QMenu): self.actionMap[hItem] = QAction(trConst(nwLabels.OUTLINE_COLS[hItem]), self) self.actionMap[hItem].setCheckable(True) self.actionMap[hItem].toggled.connect( - lambda isChecked, tItem=hItem: self._columnToggled(isChecked, tItem) + lambda isChecked, tItem=hItem: self.columnToggled.emit(isChecked, tItem) ) self.addAction(self.actionMap[hItem]) @@ -522,16 +758,338 @@ class GuiOutlineHeaderMenu(QMenu): return +# END Class GuiOutlineHeaderMenu + + +class GuiOutlineDetails(QScrollArea): + + LVL_MAP = { + "H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"), + "H2": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Chapter"), + "H3": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Scene"), + "H4": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Section"), + } + + itemTagClicked = pyqtSignal(str) + + def __init__(self, theOutline): + QScrollArea.__init__(self, theOutline) + + logger.debug("Initialising GuiOutlineDetails ...") + + self.mainConf = novelwriter.CONFIG + self.theOutline = theOutline + self.theParent = theOutline.theParent + self.theProject = theOutline.theParent.theProject + self.theTheme = theOutline.theParent.theTheme + + # Sizes + minTitle = 30*self.theTheme.textNWidth + maxTitle = 40*self.theTheme.textNWidth + wCount = self.theTheme.getTextWidth("999,999") + hSpace = int(self.mainConf.pxInt(10)) + vSpace = int(self.mainConf.pxInt(4)) + + # Details Area + self.titleLabel = QLabel("%s" % self.tr("Title")) + self.fileLabel = QLabel("%s" % self.tr("Document")) + self.itemLabel = QLabel("%s" % self.tr("Status")) + self.titleValue = QLabel("") + self.fileValue = QLabel("") + self.itemValue = QLabel("") + + self.titleValue.setMinimumWidth(minTitle) + self.titleValue.setMaximumWidth(maxTitle) + self.fileValue.setMinimumWidth(minTitle) + self.fileValue.setMaximumWidth(maxTitle) + self.itemValue.setMinimumWidth(minTitle) + self.itemValue.setMaximumWidth(maxTitle) + + # Stats Area + self.cCLabel = QLabel("%s" % self.tr("Characters")) + self.wCLabel = QLabel("%s" % self.tr("Words")) + self.pCLabel = QLabel("%s" % self.tr("Paragraphs")) + self.cCValue = QLabel("") + self.wCValue = QLabel("") + self.pCValue = QLabel("") + + self.cCValue.setMinimumWidth(wCount) + self.wCValue.setMinimumWidth(wCount) + self.pCValue.setMinimumWidth(wCount) + self.cCValue.setAlignment(Qt.AlignRight) + self.wCValue.setAlignment(Qt.AlignRight) + self.pCValue.setAlignment(Qt.AlignRight) + + # Synopsis + self.synopLabel = QLabel("%s" % self.tr("Synopsis")) + self.synopValue = QLabel("") + self.synopLWrap = QHBoxLayout() + self.synopValue.setWordWrap(True) + self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.synopLWrap.addWidget(self.synopValue, 1) + + # Tags + self.povKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) + self.focKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) + self.chrKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) + self.pltKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) + self.timKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) + self.wldKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) + self.objKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) + self.entKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) + self.cstKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])) + + self.povKeyLWrap = QHBoxLayout() + self.focKeyLWrap = QHBoxLayout() + self.chrKeyLWrap = QHBoxLayout() + self.pltKeyLWrap = QHBoxLayout() + self.timKeyLWrap = QHBoxLayout() + self.wldKeyLWrap = QHBoxLayout() + self.objKeyLWrap = QHBoxLayout() + self.entKeyLWrap = QHBoxLayout() + self.cstKeyLWrap = QHBoxLayout() + + self.povKeyValue = QLabel("") + self.focKeyValue = QLabel("") + self.chrKeyValue = QLabel("") + self.pltKeyValue = QLabel("") + self.timKeyValue = QLabel("") + self.wldKeyValue = QLabel("") + self.objKeyValue = QLabel("") + self.entKeyValue = QLabel("") + self.cstKeyValue = QLabel("") + + self.povKeyValue.setWordWrap(True) + self.focKeyValue.setWordWrap(True) + self.chrKeyValue.setWordWrap(True) + self.pltKeyValue.setWordWrap(True) + self.timKeyValue.setWordWrap(True) + self.wldKeyValue.setWordWrap(True) + self.objKeyValue.setWordWrap(True) + self.entKeyValue.setWordWrap(True) + self.cstKeyValue.setWordWrap(True) + + def tagClicked(link): + self.itemTagClicked.emit(link) + + self.povKeyValue.linkActivated.connect(tagClicked) + self.focKeyValue.linkActivated.connect(tagClicked) + self.chrKeyValue.linkActivated.connect(tagClicked) + self.pltKeyValue.linkActivated.connect(tagClicked) + self.timKeyValue.linkActivated.connect(tagClicked) + self.wldKeyValue.linkActivated.connect(tagClicked) + self.objKeyValue.linkActivated.connect(tagClicked) + self.entKeyValue.linkActivated.connect(tagClicked) + self.cstKeyValue.linkActivated.connect(tagClicked) + + self.povKeyLWrap.addWidget(self.povKeyValue, 1) + self.focKeyLWrap.addWidget(self.focKeyValue, 1) + self.chrKeyLWrap.addWidget(self.chrKeyValue, 1) + self.pltKeyLWrap.addWidget(self.pltKeyValue, 1) + self.timKeyLWrap.addWidget(self.timKeyValue, 1) + self.wldKeyLWrap.addWidget(self.wldKeyValue, 1) + self.objKeyLWrap.addWidget(self.objKeyValue, 1) + self.entKeyLWrap.addWidget(self.entKeyValue, 1) + self.cstKeyLWrap.addWidget(self.cstKeyValue, 1) + + # Selected Item Details + self.mainGroup = QGroupBox(self.tr("Title Details"), self) + self.mainForm = QGridLayout() + self.mainGroup.setLayout(self.mainForm) + + self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) + self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) + self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) + self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) + self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) + + self.mainForm.setColumnStretch(1, 1) + self.mainForm.setRowStretch(4, 1) + self.mainForm.setHorizontalSpacing(hSpace) + self.mainForm.setVerticalSpacing(vSpace) + + # Selected Item Tags + self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self) + self.tagsForm = QGridLayout() + self.tagsGroup.setLayout(self.tagsForm) + + self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) + self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) + + self.tagsForm.setColumnStretch(1, 1) + self.tagsForm.setRowStretch(8, 1) + self.tagsForm.setHorizontalSpacing(hSpace) + self.tagsForm.setVerticalSpacing(vSpace) + + # Assemble + self.outerWidget = QWidget() + self.outerBox = QHBoxLayout() + self.outerBox.addWidget(self.mainGroup, 0) + self.outerBox.addWidget(self.tagsGroup, 1) + + self.outerWidget.setLayout(self.outerBox) + self.setWidget(self.outerWidget) + + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setWidgetResizable(True) + self.setFrameStyle(QFrame.NoFrame) + + self.initDetails() + + logger.debug("GuiOutlineDetails initialisation complete") + + return + + def initDetails(self): + """Set or update outline 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) + + self.updateClasses() + + return + + def clearDetails(self): + """Clear all the data labels. + """ + self.titleLabel.setText("%s" % self.tr("Title")) + self.titleValue.setText("") + self.fileValue.setText("") + self.itemValue.setText("") + self.cCValue.setText("") + self.wCValue.setText("") + self.pCValue.setText("") + self.synopValue.setText("") + self.povKeyValue.setText("") + self.focKeyValue.setText("") + self.chrKeyValue.setText("") + self.pltKeyValue.setText("") + self.timKeyValue.setText("") + self.wldKeyValue.setText("") + self.objKeyValue.setText("") + self.entKeyValue.setText("") + self.cstKeyValue.setText("") + self.updateClasses() + return + ## # Slots ## - def _columnToggled(self, isChecked, theItem): - """The user has toggled the visibility of a column. Forward the - event to the parent class only if we're accepting changes. + @pyqtSlot(str, str) + def showItem(self, tHandle, sTitle): + """Update the content of the tree with the given handle and line + number pointing to a header. """ - if self.acceptToggle: - self.theParent._menuColumnToggled(isChecked, theItem) + pIndex = self.theProject.index + nwItem = self.theProject.tree[tHandle] + novIdx = pIndex.getNovelData(tHandle, sTitle) + theRefs = pIndex.getReferences(tHandle, sTitle) + if nwItem is None or novIdx is None: + return False + + if novIdx.level in self.LVL_MAP: + self.titleLabel.setText("%s" % self.tr(self.LVL_MAP[novIdx.level])) + else: + self.titleLabel.setText("%s" % self.tr("Title")) + self.titleValue.setText(novIdx.title) + + itemStatus, _ = nwItem.getImportStatus() + + self.fileValue.setText(nwItem.itemName) + self.itemValue.setText(itemStatus) + + cC = checkInt(novIdx.charCount, 0) + wC = checkInt(novIdx.wordCount, 0) + pC = checkInt(novIdx.paraCount, 0) + + self.cCValue.setText(f"{cC:n}") + self.wCValue.setText(f"{wC:n}") + self.pCValue.setText(f"{pC:n}") + + self.synopValue.setText(novIdx.synopsis) + + self.povKeyValue.setText(self._formatTags(theRefs, nwKeyWords.POV_KEY)) + self.focKeyValue.setText(self._formatTags(theRefs, nwKeyWords.FOCUS_KEY)) + self.chrKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CHAR_KEY)) + self.pltKeyValue.setText(self._formatTags(theRefs, nwKeyWords.PLOT_KEY)) + self.timKeyValue.setText(self._formatTags(theRefs, nwKeyWords.TIME_KEY)) + self.wldKeyValue.setText(self._formatTags(theRefs, nwKeyWords.WORLD_KEY)) + self.objKeyValue.setText(self._formatTags(theRefs, nwKeyWords.OBJECT_KEY)) + self.entKeyValue.setText(self._formatTags(theRefs, nwKeyWords.ENTITY_KEY)) + self.cstKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CUSTOM_KEY)) + + return True + + @pyqtSlot() + def updateClasses(self): + """Update the visibility status of class details. + """ + usedClasses = self.theProject.tree.rootClasses() + + pltVisible = nwItemClass.PLOT in usedClasses + timVisible = nwItemClass.TIMELINE in usedClasses + wldVisible = nwItemClass.WORLD in usedClasses + objVisible = nwItemClass.OBJECT in usedClasses + entVisible = nwItemClass.ENTITY in usedClasses + cstVisible = nwItemClass.CUSTOM in usedClasses + + self.pltKeyLabel.setVisible(pltVisible) + self.pltKeyValue.setVisible(pltVisible) + self.timKeyLabel.setVisible(timVisible) + self.timKeyValue.setVisible(timVisible) + self.wldKeyLabel.setVisible(wldVisible) + self.wldKeyValue.setVisible(wldVisible) + self.objKeyLabel.setVisible(objVisible) + self.objKeyValue.setVisible(objVisible) + self.entKeyLabel.setVisible(entVisible) + self.entKeyValue.setVisible(entVisible) + self.cstKeyLabel.setVisible(cstVisible) + self.cstKeyValue.setVisible(cstVisible) + return -# END Class GuiOutlineHeaderMenu + @staticmethod + def _formatTags(refs, key): + """Convert a list of tags into a list of clickable tag links. + """ + return ", ".join( + [f"{tag}" for tag in refs.get(key, [])] + ) + +# END Class GuiOutlineDetails diff --git a/novelwriter/gui/outlinedetails.py b/novelwriter/gui/outlinedetails.py deleted file mode 100644 index f6f86e67..00000000 --- a/novelwriter/gui/outlinedetails.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -novelWriter – GUI Project Outline Details -========================================= -GUI class for the project outline details panel - -File History: -Created: 2020-06-02 [0.7.0] - -This file is a part of novelWriter -Copyright 2018–2022, Veronica Berglyd Olsen - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -import logging -import novelwriter - -from PyQt5.QtCore import Qt, QT_TRANSLATE_NOOP, pyqtSignal -from PyQt5.QtWidgets import ( - QScrollArea, QWidget, QGridLayout, QHBoxLayout, QGroupBox, QLabel, QFrame -) - -from novelwriter.enum import nwView -from novelwriter.common import checkInt -from novelwriter.constants import trConst, nwKeyWords, nwLabels - -logger = logging.getLogger(__name__) - - -class GuiOutlineDetails(QScrollArea): - - LVL_MAP = { - "H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"), - "H2": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Chapter"), - "H3": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Scene"), - "H4": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Section"), - } - - viewChangeRequested = pyqtSignal(nwView) - - def __init__(self, theParent): - QScrollArea.__init__(self, theParent) - - logger.debug("Initialising GuiOutlineDetails ...") - - self.mainConf = novelwriter.CONFIG - self.theParent = theParent - self.theProject = theParent.theProject - self.theTheme = theParent.theTheme - - # Sizes - minTitle = 30*self.theTheme.textNWidth - maxTitle = 40*self.theTheme.textNWidth - wCount = self.theTheme.getTextWidth("999,999") - hSpace = int(self.mainConf.pxInt(10)) - vSpace = int(self.mainConf.pxInt(4)) - - # Details Area - self.titleLabel = QLabel("%s" % self.tr("Title")) - self.fileLabel = QLabel("%s" % self.tr("Document")) - self.itemLabel = QLabel("%s" % self.tr("Status")) - self.titleValue = QLabel("") - self.fileValue = QLabel("") - self.itemValue = QLabel("") - - self.titleValue.setMinimumWidth(minTitle) - self.titleValue.setMaximumWidth(maxTitle) - self.fileValue.setMinimumWidth(minTitle) - self.fileValue.setMaximumWidth(maxTitle) - self.itemValue.setMinimumWidth(minTitle) - self.itemValue.setMaximumWidth(maxTitle) - - # Stats Area - self.cCLabel = QLabel("%s" % self.tr("Characters")) - self.wCLabel = QLabel("%s" % self.tr("Words")) - self.pCLabel = QLabel("%s" % self.tr("Paragraphs")) - self.cCValue = QLabel("") - self.wCValue = QLabel("") - self.pCValue = QLabel("") - - self.cCValue.setMinimumWidth(wCount) - self.wCValue.setMinimumWidth(wCount) - self.pCValue.setMinimumWidth(wCount) - self.cCValue.setAlignment(Qt.AlignRight) - self.wCValue.setAlignment(Qt.AlignRight) - self.pCValue.setAlignment(Qt.AlignRight) - - # Synopsis - self.synopLabel = QLabel("%s" % self.tr("Synopsis")) - self.synopValue = QLabel("") - self.synopLWrap = QHBoxLayout() - self.synopValue.setWordWrap(True) - self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft) - self.synopLWrap.addWidget(self.synopValue, 1) - - # Tags - self.povKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) - self.focKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) - self.chrKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) - self.pltKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) - self.timKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) - self.wldKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) - self.objKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) - self.entKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) - self.cstKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])) - - self.povKeyLWrap = QHBoxLayout() - self.focKeyLWrap = QHBoxLayout() - self.chrKeyLWrap = QHBoxLayout() - self.pltKeyLWrap = QHBoxLayout() - self.timKeyLWrap = QHBoxLayout() - self.wldKeyLWrap = QHBoxLayout() - self.objKeyLWrap = QHBoxLayout() - self.entKeyLWrap = QHBoxLayout() - self.cstKeyLWrap = QHBoxLayout() - - self.povKeyValue = QLabel("") - self.focKeyValue = QLabel("") - self.chrKeyValue = QLabel("") - self.pltKeyValue = QLabel("") - self.timKeyValue = QLabel("") - self.wldKeyValue = QLabel("") - self.objKeyValue = QLabel("") - self.entKeyValue = QLabel("") - self.cstKeyValue = QLabel("") - - self.povKeyValue.setWordWrap(True) - self.focKeyValue.setWordWrap(True) - self.chrKeyValue.setWordWrap(True) - self.pltKeyValue.setWordWrap(True) - self.timKeyValue.setWordWrap(True) - self.wldKeyValue.setWordWrap(True) - self.objKeyValue.setWordWrap(True) - self.entKeyValue.setWordWrap(True) - self.cstKeyValue.setWordWrap(True) - - self.povKeyValue.linkActivated.connect(self._tagClicked) - self.focKeyValue.linkActivated.connect(self._tagClicked) - self.chrKeyValue.linkActivated.connect(self._tagClicked) - self.pltKeyValue.linkActivated.connect(self._tagClicked) - self.timKeyValue.linkActivated.connect(self._tagClicked) - self.wldKeyValue.linkActivated.connect(self._tagClicked) - self.objKeyValue.linkActivated.connect(self._tagClicked) - self.entKeyValue.linkActivated.connect(self._tagClicked) - self.cstKeyValue.linkActivated.connect(self._tagClicked) - - self.povKeyLWrap.addWidget(self.povKeyValue, 1) - self.focKeyLWrap.addWidget(self.focKeyValue, 1) - self.chrKeyLWrap.addWidget(self.chrKeyValue, 1) - self.pltKeyLWrap.addWidget(self.pltKeyValue, 1) - self.timKeyLWrap.addWidget(self.timKeyValue, 1) - self.wldKeyLWrap.addWidget(self.wldKeyValue, 1) - self.objKeyLWrap.addWidget(self.objKeyValue, 1) - self.entKeyLWrap.addWidget(self.entKeyValue, 1) - self.cstKeyLWrap.addWidget(self.cstKeyValue, 1) - - # Selected Item Details - self.mainGroup = QGroupBox(self.tr("Title Details"), self) - self.mainForm = QGridLayout() - self.mainGroup.setLayout(self.mainForm) - - self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) - self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) - self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, Qt.AlignTop | Qt.AlignRight) - self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) - self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft) - - self.mainForm.setColumnStretch(1, 1) - self.mainForm.setRowStretch(4, 1) - self.mainForm.setHorizontalSpacing(hSpace) - self.mainForm.setVerticalSpacing(vSpace) - - # Selected Item Tags - self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self) - self.tagsForm = QGridLayout() - self.tagsGroup.setLayout(self.tagsForm) - - self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft) - self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft) - - self.tagsForm.setColumnStretch(1, 1) - self.tagsForm.setRowStretch(8, 1) - self.tagsForm.setHorizontalSpacing(hSpace) - self.tagsForm.setVerticalSpacing(vSpace) - - # Assemble - self.outerWidget = QWidget() - self.outerBox = QHBoxLayout() - self.outerBox.addWidget(self.mainGroup, 0) - self.outerBox.addWidget(self.tagsGroup, 1) - - self.outerWidget.setLayout(self.outerBox) - self.setWidget(self.outerWidget) - - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setWidgetResizable(True) - self.setFrameStyle(QFrame.NoFrame) - - self.initDetails() - - logger.debug("GuiOutlineDetails initialisation complete") - - return - - def initDetails(self): - """Set or update outline settings. - """ - # Scroll bars - if self.mainConf.hideVScroll: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - else: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - if self.mainConf.hideHScroll: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - else: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - return - - def clearDetails(self): - """Clear all the data labels. - """ - self.titleLabel.setText("%s" % self.tr("Title")) - self.titleValue.setText("") - self.fileValue.setText("") - self.itemValue.setText("") - self.cCValue.setText("") - self.wCValue.setText("") - self.pCValue.setText("") - self.synopValue.setText("") - self.povKeyValue.setText("") - self.focKeyValue.setText("") - self.chrKeyValue.setText("") - self.pltKeyValue.setText("") - self.timKeyValue.setText("") - self.wldKeyValue.setText("") - self.objKeyValue.setText("") - self.entKeyValue.setText("") - self.cstKeyValue.setText("") - return - - def showItem(self, tHandle, sTitle): - """Update the content of the tree with the given handle and line - number pointing to a header. - """ - pIndex = self.theProject.index - nwItem = self.theProject.tree[tHandle] - novIdx = pIndex.getNovelData(tHandle, sTitle) - theRefs = pIndex.getReferences(tHandle, sTitle) - if nwItem is None or novIdx is None: - return False - - if novIdx.level in self.LVL_MAP: - self.titleLabel.setText("%s" % self.tr(self.LVL_MAP[novIdx.level])) - else: - self.titleLabel.setText("%s" % self.tr("Title")) - self.titleValue.setText(novIdx.title) - - itemStatus, _ = nwItem.getImportStatus() - - self.fileValue.setText(nwItem.itemName) - self.itemValue.setText(itemStatus) - - cC = checkInt(novIdx.charCount, 0) - wC = checkInt(novIdx.wordCount, 0) - pC = checkInt(novIdx.paraCount, 0) - - self.cCValue.setText(f"{cC:n}") - self.wCValue.setText(f"{wC:n}") - self.pCValue.setText(f"{pC:n}") - - self.synopValue.setText(novIdx.synopsis) - - self.povKeyValue.setText(self._formatTags(theRefs, nwKeyWords.POV_KEY)) - self.focKeyValue.setText(self._formatTags(theRefs, nwKeyWords.FOCUS_KEY)) - self.chrKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CHAR_KEY)) - self.pltKeyValue.setText(self._formatTags(theRefs, nwKeyWords.PLOT_KEY)) - self.timKeyValue.setText(self._formatTags(theRefs, nwKeyWords.TIME_KEY)) - self.wldKeyValue.setText(self._formatTags(theRefs, nwKeyWords.WORLD_KEY)) - self.objKeyValue.setText(self._formatTags(theRefs, nwKeyWords.OBJECT_KEY)) - self.entKeyValue.setText(self._formatTags(theRefs, nwKeyWords.ENTITY_KEY)) - self.cstKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CUSTOM_KEY)) - - return True - - ## - # Slots - ## - - def _tagClicked(self, theLink): - """Capture the click of a tag in the right-most column. - """ - logger.verbose("Clicked link: '%s'", theLink) - if len(theLink) > 0: - theBits = theLink.split("=") - if len(theBits) == 2: - self.viewChangeRequested.emit(nwView.PROJECT) - self.theParent.docViewer.loadFromTag(theBits[1]) - return - - ## - # Internal Functions - ## - - def _formatTags(self, theRefs, theKey): - """Format the tags as clickable links. - """ - if theKey not in theRefs: - return "" - refTags = [] - for tTag in theRefs[theKey]: - refTags.append("%s" % ( - theKey[1:], tTag, tTag - )) - return ", ".join(refTags) - -# END Class GuiOutlineDetails diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index e35662f1..e0a8ef62 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -32,11 +32,14 @@ from time import time from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( - QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction, QFrame + QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction, QFrame, + QDialog ) from novelwriter.core import NWDoc from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert +from novelwriter.common import minmax +from novelwriter.dialogs.itemeditor import GuiItemEditor logger = logging.getLogger(__name__) @@ -48,8 +51,9 @@ class GuiProjectTree(QTreeWidget): C_EXPORT = 2 C_STATUS = 3 - novelItemChanged = pyqtSignal() - noteItemChanged = pyqtSignal() + treeItemChanged = pyqtSignal(str) + novelItemChanged = pyqtSignal(str) + rootFolderChanged = pyqtSignal(str) wordCountsChanged = pyqtSignal() def __init__(self, theParent): @@ -63,10 +67,9 @@ class GuiProjectTree(QTreeWidget): self.theProject = theParent.theProject # Internal Variables - self._treeMap = {} - self._treeChanged = False + self._treeMap = {} + self._lastMove = {} self._timeChanged = 0 - self._lastMove = {} ## # Build GUI @@ -156,15 +159,15 @@ class GuiProjectTree(QTreeWidget): """ self.clear() self._treeMap = {} - self._treeChanged = False + self._lastMove = {} self._timeChanged = 0 return def newTreeItem(self, itemType, itemClass=None): """Add new item to the tree, with a given itemType (and - itemClass if Root), and attach it to the selected handle. Also make - sure the item is added in a place it can be added, and that other - meta data is set correctly to ensure a valid project tree. + itemClass if Root), and attach it to the selected handle. Also + make sure the item is added in a place it can be added, and that + other meta data is set correctly to ensure a valid project tree. """ if not self.theParent.hasProject: logger.error("No project open") @@ -186,9 +189,11 @@ class GuiProjectTree(QTreeWidget): ), nwAlert.ERROR) return False - # If the selected item is a file, the new item will be a sibling + # If the selected item is a file, the new item will be a + # sibling if the file has no children, otherwise a child pItem = self.theProject.tree[sHandle] - if pItem.itemType == nwItemType.FILE: + qItem = self._getTreeItem(sHandle) + if pItem.itemType == nwItemType.FILE and qItem.childCount() == 0: nHandle = sHandle sHandle = pItem.itemParent if sHandle is None: @@ -221,9 +226,9 @@ class GuiProjectTree(QTreeWidget): # Add the new item to the tree self.revealNewTreeItem(tHandle, nHandle) self.theParent.editItem(tHandle) - nwItem = self.theProject.tree[tHandle] - # If this is a folder, return here + # Handle new file creation + nwItem = self.theProject.tree[tHandle] if nwItem.itemType != nwItemType.FILE: return True @@ -231,7 +236,9 @@ class GuiProjectTree(QTreeWidget): newDoc = NWDoc(self.theProject, tHandle) if not newDoc.readDocument(): if nwItem.itemLayout == nwItemLayout.DOCUMENT: - newText = f"### {nwItem.itemName}\n\n" + iLvl = self.theProject.index.getHandleHeaderIntLevel(sHandle) + hLvl = "#"*minmax(iLvl + 1, 2, 4) + newText = f"{hLvl} {nwItem.itemName}\n\n" else: newText = f"# {nwItem.itemName}\n\n" @@ -266,7 +273,7 @@ class GuiProjectTree(QTreeWidget): if pHandle is not None and pHandle in self._treeMap: self._treeMap[pHandle].setExpanded(True) - self._emitItemChange(tHandle) + self._alertTreeChange(tHandle=tHandle, flush=True) self.clearSelection() trItem.setSelected(True) @@ -308,10 +315,31 @@ class GuiProjectTree(QTreeWidget): pItem.insertChild(nIndex, cItem) self._recordLastMove(cItem, pItem, tIndex) + self._alertTreeChange(tHandle=tHandle, flush=True) self.clearSelection() cItem.setSelected(True) - self._setTreeChanged(True) - self._emitItemChange(tHandle) + + return True + + def editTreeItem(self, tHandle=None): + """Open the edit item dialog. + """ + if tHandle is None: + logger.warning("No item selected") + return False + + tItem = self.theProject.tree[tHandle] + if tItem is None: + return False + if tItem.itemType == nwItemType.NO_TYPE: + return False + + logger.verbose("Requesting change to item '%s'", tHandle) + dlgProj = GuiItemEditor(self, tHandle) + dlgProj.exec_() + if dlgProj.result() == QDialog.Accepted: + self.setTreeItemValues(tHandle) + self._alertTreeChange(tHandle=tHandle, flush=False) return True @@ -328,16 +356,6 @@ class GuiProjectTree(QTreeWidget): self.theProject.setTreeOrder(theList) return True - def flushTreeOrder(self): - """Calls saveTreeOrder if there are unsaved changes, otherwise - does nothing. - """ - if self._treeChanged: - logger.verbose("Flushing project tree to project class") - self.saveTreeOrder() - self._setTreeChanged(False) - return - def getTreeFromHandle(self, tHandle): """Recursively return all the children items starting from a given item handle. @@ -409,7 +427,7 @@ class GuiProjectTree(QTreeWidget): self.deleteItem(tHandle, alreadyAsked=True, bulkAction=True) if nTrash > 0: - self._setTreeChanged(True) + self._alertTreeChange(tHandle=trashHandle, flush=True) return True @@ -443,6 +461,7 @@ class GuiProjectTree(QTreeWidget): return False wCount = self._getItemWordCount(tHandle) + autoFlush = not bulkAction if nwItemS.itemType == nwItemType.ROOT: # Only an empty ROOT folder can be deleted logger.debug("User requested a root folder '%s' deleted", tHandle) @@ -450,7 +469,7 @@ class GuiProjectTree(QTreeWidget): if trItemS.childCount() == 0: self.takeTopLevelItem(tIndex) self._deleteTreeItem(tHandle) - self._setTreeChanged(True) + self._alertTreeChange(tHandle=tHandle, flush=True) else: self.theParent.makeAlert(self.tr( "Cannot delete root folder. It is not empty. " @@ -466,7 +485,7 @@ class GuiProjectTree(QTreeWidget): tIndex = trItemP.indexOfChild(trItemS) trItemP.takeChild(tIndex) self._deleteTreeItem(tHandle) - self._setTreeChanged(True) + self._alertTreeChange(tHandle=tHandle, flush=autoFlush) else: # A populated FOLDER or a FILE requires confirmtation @@ -502,7 +521,7 @@ class GuiProjectTree(QTreeWidget): self.theParent.closeDocument() self._deleteTreeItem(dHandle) - self._setTreeChanged(True) + self._alertTreeChange(tHandle=tHandle, flush=autoFlush) self.wordCountsChanged.emit() else: @@ -521,7 +540,7 @@ class GuiProjectTree(QTreeWidget): trItemT.addChild(trItemC) self._postItemMove(tHandle, wCount) self._recordLastMove(trItemS, trItemP, tIndex) - self._setTreeChanged(True) + self._alertTreeChange(tHandle=tHandle, flush=autoFlush) return True @@ -542,7 +561,7 @@ class GuiProjectTree(QTreeWidget): else: expIcon = self.theTheme.getIcon("cross") - itempStatus, statusIcon = nwItem.getImportStatus() + itemStatus, statusIcon = nwItem.getImportStatus() hLevel = self.theProject.index.getHandleHeaderLevel(tHandle) itemIcon = self.theTheme.getItemIcon( nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel @@ -552,7 +571,7 @@ class GuiProjectTree(QTreeWidget): trItem.setText(self.C_NAME, nwItem.itemName) trItem.setIcon(self.C_EXPORT, expIcon) trItem.setIcon(self.C_STATUS, statusIcon) - trItem.setToolTip(self.C_STATUS, itempStatus) + trItem.setToolTip(self.C_STATUS, itemStatus) if self.mainConf.emphLabels and nwItem.itemLayout == nwItemLayout.DOCUMENT: trFont = trItem.font(self.C_NAME) @@ -657,6 +676,7 @@ class GuiProjectTree(QTreeWidget): dstItem.insertChild(dstIndex, movItem) self._postItemMove(sHandle, wCount) + self._alertTreeChange(tHandle=sHandle, flush=True) self.clearSelection() movItem.setSelected(True) @@ -785,6 +805,7 @@ class GuiProjectTree(QTreeWidget): QTreeWidget.dropEvent(self, theEvent) self._postItemMove(sHandle, wCount) self._recordLastMove(sItem, pItem, pIndex) + self._alertTreeChange(tHandle=sHandle, flush=True) sItem.setExpanded(isExpanded) return @@ -826,8 +847,6 @@ class GuiProjectTree(QTreeWidget): # Trigger dependent updates self.propagateCount(tHandle, wCount) - self._setTreeChanged(True) - self._emitItemChange(tHandle) return True @@ -920,7 +939,7 @@ class GuiProjectTree(QTreeWidget): except Exception: logger.error("Failed to get index of item with handle '%s'", nHandle) if byIndex >= 0: - self._treeMap[pHandle].insertChild(byIndex+1, newItem) + self._treeMap[pHandle].insertChild(byIndex + 1, newItem) else: self._treeMap[pHandle].addChild(newItem) self.propagateCount(tHandle, nwItem.wordCount, countChildren=True) @@ -928,8 +947,6 @@ class GuiProjectTree(QTreeWidget): self.setTreeItemValues(tHandle) newItem.setExpanded(nwItem.isExpanded) - self._setTreeChanged(True) - return newItem def _addTrashRoot(self): @@ -942,33 +959,34 @@ class GuiProjectTree(QTreeWidget): trItem = self._getTreeItem(trashHandle) if trItem is None: - trItem = self._addTreeItem( - self.theProject.tree[trashHandle] - ) + trItem = self._addTreeItem(self.theProject.tree[trashHandle]) if trItem is not None: trItem.setExpanded(True) - self._setTreeChanged(True) + self._alertTreeChange(tHandle=trashHandle, flush=True) return trItem - def _setTreeChanged(self, theState): - """Set the tree change flag, and propagate to the project. + def _alertTreeChange(self, tHandle=None, flush=True): + """Update information on tree change state, and emit necessary + signals. """ - self._treeChanged = theState - if theState: - self._timeChanged = time() - self.theProject.setProjectChanged(True) - return + self._timeChanged = time() + self.theProject.setProjectChanged(True) + if flush: + self.saveTreeOrder() + + tItem = self.theProject.tree[tHandle] + if tItem is None: + return + + itemType = tItem.itemType + if itemType == nwItemType.ROOT: + self.rootFolderChanged.emit(tHandle) + elif itemType == nwItemType.FILE and tItem.isNovelLike(): + self.novelItemChanged.emit(tHandle) + + self.treeItemChanged.emit(tHandle) - def _emitItemChange(self, tHandle): - """Emit an item change signal for a given handle. - """ - if self.theProject.tree.checkType(tHandle, nwItemType.FILE): - nwItem = self.theProject.tree[tHandle] - if nwItem.isNovelLike(): - self.novelItemChanged.emit() - else: - self.noteItemChanged.emit() return def _recordLastMove(self, srcItem, parItem, parIndex): diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 7c0e3ce6..bb93e0b8 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -466,8 +466,8 @@ class GuiIcons: # General Button Icons "add", "backward", "check", "clear", "close", "cross", "delete", "done", "down", "edit", - "forward", "hash", "maximise", "minimise", "reference", "refresh", "remove", "save", - "search_replace", "search", "settings", "up", + "forward", "hash", "maximise", "menu", "minimise", "reference", "refresh", "remove", + "save", "search_replace", "search", "settings", "up", # Switches "sticky-on", "sticky-off", diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 3d61a65e..6a056dca 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -27,6 +27,7 @@ import os import logging import novelwriter +from enum import Enum from time import time from datetime import datetime @@ -39,20 +40,19 @@ from PyQt5.QtWidgets import ( from novelwriter.gui import ( GuiDocEditor, GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiMainMenu, - GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails, GuiProjectTree, - GuiTheme, GuiViewsBar + GuiMainStatus, GuiNovelTree, GuiOutline, GuiProjectTree, GuiTheme, + GuiViewsBar ) from novelwriter.dialogs import ( - GuiAbout, GuiDocMerge, GuiDocSplit, GuiItemEditor, GuiPreferences, - GuiProjectDetails, GuiProjectLoad, GuiProjectSettings, GuiUpdates, - GuiWordList + GuiAbout, GuiDocMerge, GuiDocSplit, GuiPreferences, GuiProjectDetails, + GuiProjectLoad, GuiProjectSettings, GuiUpdates, GuiWordList ) from novelwriter.tools import ( GuiBuildNovel, GuiLipsum, GuiProjectWizard, GuiWritingStats ) from novelwriter.core import NWProject from novelwriter.enum import ( - nwItemType, nwItemClass, nwAlert, nwWidget, nwState, nwView + nwDocMode, nwItemType, nwItemClass, nwAlert, nwWidget, nwState, nwView ) from novelwriter.common import getGuiItem, hexToInt @@ -112,23 +112,30 @@ class GuiMain(QMainWindow): self.docViewer = GuiDocViewer(self) self.treeMeta = GuiItemDetails(self) self.projView = GuiOutline(self) - self.projMeta = GuiOutlineDetails(self) self.mainMenu = GuiMainMenu(self) self.viewsBar = GuiViewsBar(self) # Connect Signals Between Main Elements - self.docEditor.spellDictionaryChanged.connect(self.statusBar.setLanguage) - self.docEditor.docEditedStatusChanged.connect(self.statusBar.doUpdateDocumentStatus) - self.docEditor.docCountsChanged.connect(self.treeMeta.doUpdateCounts) - self.docEditor.docCountsChanged.connect(self.treeView.doUpdateCounts) + self.viewsBar.viewChangeRequested.connect(self._changeView) self.treeView.itemSelectionChanged.connect(self._treeSingleClick) self.treeView.itemDoubleClicked.connect(self._treeDoubleClick) self.treeView.novelItemChanged.connect(self._treeNovelItemChanged) self.treeView.wordCountsChanged.connect(self._updateStatusWordCount) + self.treeView.treeItemChanged.connect(self.docEditor.updateDocInfo) + self.treeView.treeItemChanged.connect(self.docViewer.updateDocInfo) + self.treeView.treeItemChanged.connect(self.treeMeta.updateViewBox) + self.treeView.rootFolderChanged.connect(self.projView.updateRootItem) - self.viewsBar.viewChangeRequested.connect(self._changeView) - self.projMeta.viewChangeRequested.connect(self._changeView) + self.docEditor.spellDictionaryChanged.connect(self.statusBar.setLanguage) + self.docEditor.docEditedStatusChanged.connect(self.statusBar.doUpdateDocumentStatus) + self.docEditor.docCountsChanged.connect(self.treeMeta.doUpdateCounts) + self.docEditor.docCountsChanged.connect(self.treeView.doUpdateCounts) + self.docEditor.loadDocumentTagRequest.connect(self._followTag) + + self.docViewer.loadDocumentTagRequest.connect(self._followTag) + + self.projView.loadDocumentTagRequest.connect(self._followTag) # Project Tree Stack self.projStack = QStackedWidget() @@ -158,12 +165,6 @@ class GuiMain(QMainWindow): self.splitDocs.addWidget(self.splitView) self.splitDocs.setHandleWidth(hWd) - # Splitter : Project Outlie / Outline Details - self.splitOutline = QSplitter(Qt.Vertical) - self.splitOutline.addWidget(self.projView) - self.splitOutline.addWidget(self.projMeta) - self.splitOutline.setSizes(self.mainConf.getOutlinePanePos()) - # Splitter : Project Tree / Main Tabs self.splitMain = QSplitter(Qt.Horizontal) self.splitMain.setContentsMargins(0, 0, 0, 0) @@ -175,7 +176,7 @@ class GuiMain(QMainWindow): # Main Stack : Editor / Outline self.mainStack = QStackedWidget() self.mainStack.addWidget(self.splitMain) - self.mainStack.addWidget(self.splitOutline) + self.mainStack.addWidget(self.projView) self.mainStack.currentChanged.connect(self._mainStackChanged) # Indices of Splitter Widgets @@ -188,7 +189,7 @@ class GuiMain(QMainWindow): # Indices of Tab Widgets self.idxEditorView = self.mainStack.indexOf(self.splitMain) - self.idxOutlineView = self.mainStack.indexOf(self.splitOutline) + self.idxOutlineView = self.mainStack.indexOf(self.projView) self.idxTreeView = self.projStack.indexOf(self.treeView) self.idxNovelView = self.projStack.indexOf(self.novelView) @@ -296,7 +297,7 @@ class GuiMain(QMainWindow): self.docEditor.clearEditor() self.docEditor.setDictionaries() self.closeDocViewer() - self.projMeta.clearDetails() + self.projView.clearOutline() # General self.statusBar.clearStatus() @@ -358,6 +359,7 @@ class GuiMain(QMainWindow): self.rebuildTrees() self.saveProject() self.docEditor.setDictionaries() + self.projView.updateRootItem(None) self.rebuildIndex(beQuiet=True) self.statusBar.setRefTime(self.theProject.projOpened) self.statusBar.setProjectStatus(nwState.GOOD) @@ -503,8 +505,8 @@ class GuiMain(QMainWindow): self.rebuildTrees() self.docEditor.setDictionaries() self.docEditor.toggleSpellCheck(self.theProject.spellCheck) - self.mainMenu.setAutoOutline(self.theProject.autoOutline) self.statusBar.setRefTime(self.theProject.projOpened) + self.projView.updateRootItem(None) self._updateStatusWordCount() # Restore previously open documents, if any @@ -596,7 +598,6 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - self.treeView.flushTreeOrder() nHandle = None # The next handle after tHandle fHandle = None # The first file handle we encounter foundIt = False # We've found tHandle, pick the next we see @@ -790,7 +791,7 @@ class GuiMain(QMainWindow): tHandle = self.treeView.getSelectedHandle() elif self.novelView.hasFocus(): tHandle, tLine = self.novelView.getSelectedHandle() - elif self.projView.hasFocus(): + elif self.projView.treeFocus(): tHandle, tLine = self.projView.getSelectedHandle() else: logger.warning("No item selected") @@ -813,27 +814,10 @@ class GuiMain(QMainWindow): tHandle = self.docEditor.docHandle() else: tHandle = self.treeView.getSelectedHandle() + if tHandle: + return self.treeView.editTreeItem(tHandle) - if tHandle is None: - logger.warning("No item selected") - return False - - tItem = self.theProject.tree[tHandle] - if tItem is None: - return False - if tItem.itemType == nwItemType.NO_TYPE: - return False - - logger.verbose("Requesting change to item '%s'", tHandle) - dlgProj = GuiItemEditor(self, tHandle) - dlgProj.exec_() - if dlgProj.result() == QDialog.Accepted: - self.treeView.setTreeItemValues(tHandle) - self.treeMeta.updateViewBox(tHandle) - self.docEditor.updateDocInfo(tHandle) - self.docViewer.updateDocInfo(tHandle) - - return True + return False def rebuildTrees(self): """Rebuild the project tree. @@ -889,19 +873,6 @@ class GuiMain(QMainWindow): return True - def rebuildOutline(self): - """Force a rebuild of the Outline view. - """ - if not self.hasProject: - logger.error("No project open") - return False - - logger.verbose("Forcing a rebuild of the Project Outline") - self._changeView(nwView.OUTLINE) - self.projView.refreshTree(overRide=True) - - return True - ## # Main Dialogs ## @@ -949,7 +920,6 @@ class GuiMain(QMainWindow): self.treeView.initTree() self.novelView.initTree() self.projView.initOutline() - self.projMeta.initDetails() self._updateStatusWordCount() return @@ -980,8 +950,6 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - self.treeView.flushTreeOrder() - dlgDetails = getGuiItem("GuiProjectDetails") if dlgDetails is None: dlgDetails = GuiProjectDetails(self) @@ -1186,7 +1154,7 @@ class GuiMain(QMainWindow): if not self.isFocusMode: self.mainConf.setMainPanePos(self.splitMain.sizes()) self.mainConf.setDocPanePos(self.splitDocs.sizes()) - self.mainConf.setOutlinePanePos(self.splitOutline.sizes()) + self.mainConf.setOutlinePanePos(self.projView.splitSizes()) if self.viewMeta.isVisible(): self.mainConf.setViewPanePos(self.splitView.sizes()) @@ -1223,7 +1191,7 @@ class GuiMain(QMainWindow): self.docViewer.setFocus() elif paneNo == nwWidget.OUTLINE: self._changeView(nwView.OUTLINE) - self.projView.setFocus() + self.projView.setTreeFocus() return def closeDocEditor(self): @@ -1453,6 +1421,23 @@ class GuiMain(QMainWindow): return projData + def _getTagSource(self, tTag): + """A wrapper function for the index lookup of a tag that will + display an alert if the tag cannot be found. + """ + tHandle, sTitle = self.theProject.index.getTagSource(tTag) + if tHandle is None: + self.makeAlert(self.tr( + "Could not find the reference for tag '{0}'. It either doesn't " + "exist, or the index is out of date. The index can be updated " + "from the Tools menu, or by pressing {1}." + ).format( + tTag, "F9" + ), nwAlert.ERROR) + return None, None + + return tHandle, sTitle + ## # Events ## @@ -1468,9 +1453,21 @@ class GuiMain(QMainWindow): return ## - # Slots + # Private Slots ## + @pyqtSlot(str, Enum) + def _followTag(self, tTag, tMode): + """Follow a tag after user interaction with a link. + """ + tHandle, sTitle = self._getTagSource(tTag) + if tHandle is not None: + if tMode == nwDocMode.EDIT: + self.openDocument(tHandle) + elif tMode == nwDocMode.VIEW: + self.viewDocument(tHandle=tHandle, tAnchor=f"#{sTitle}") + return + @pyqtSlot(nwView) def _changeView(self, view): """Handle the requested change of view from the GuiViewBar. @@ -1488,7 +1485,7 @@ class GuiMain(QMainWindow): self.projStack.setCurrentWidget(self.novelView) elif view == nwView.OUTLINE: - self.mainStack.setCurrentWidget(self.splitOutline) + self.mainStack.setCurrentWidget(self.projView) return @@ -1568,8 +1565,7 @@ class GuiMain(QMainWindow): if self.mainStack.currentIndex() == self.idxOutlineView: logger.verbose("Novel tree changed while Outline tab active") if self.hasProject: - self.treeView.flushTreeOrder() - self.projView.refreshTree(novelChanged=True) + self.projView.refreshView(novelChanged=True) return @@ -1602,7 +1598,7 @@ class GuiMain(QMainWindow): elif tabIndex == self.idxOutlineView: logger.verbose("Project outline tab activated") if self.hasProject: - self.projView.refreshTree() + self.projView.refreshView() return diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index 06849ee9..66f203bd 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -709,7 +709,6 @@ class GuiBuildNovel(QDialog): bldObj.initDocument() # Make sure the project and document is up to date - self.theParent.treeView.flushTreeOrder() self.theParent.saveDocument() self.buildProgress.setMaximum(len(self.theProject.tree)) diff --git a/sample/content/a520879ca0b45.nwd b/sample/content/a520879ca0b45.nwd new file mode 100644 index 00000000..12d7a62a --- /dev/null +++ b/sample/content/a520879ca0b45.nwd @@ -0,0 +1,17 @@ +%%~name: Chapter One +%%~path: e5e47ebf63b1c/a520879ca0b45 +%%~kind: NOVEL/DOCUMENT +### Chapter One + +@pov: Jane + +% Synopsis: Remember Jane and John? + +### Scene One + +@pov: Jane +@focus: John + +A project can have multiple novel root folders for multiple novels. This is the first scene of a sequel to the first novel. + +In this way, the writer can keep the same notes for multiple novels. This can be especially useful if the writer is planning a multi-novel story in advance. diff --git a/sample/content/bacb7059e3083.nwd b/sample/content/bacb7059e3083.nwd new file mode 100644 index 00000000..b6be7a07 --- /dev/null +++ b/sample/content/bacb7059e3083.nwd @@ -0,0 +1,8 @@ +%%~name: Title Page +%%~path: e5e47ebf63b1c/bacb7059e3083 +%%~kind: NOVEL/DOCUMENT +#! Sequel Novel + +>> **By Jane Doh** << + +% Synopsis: Jane and John are back in a sequel to My Novel! diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index e53d6569..9f3a6587 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,13 +1,13 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 1329 - 220 - 67104 + 1334 + 225 + 67746 False @@ -15,10 +15,10 @@ True None True - 636b6aa9b697b + a520879ca0b45 636b6aa9b697b - 1303 - 894 + 1363 + 954 409 B @@ -33,10 +33,10 @@
- New + New Notes Started - 1st Draft + 1st Draft 2nd Draft 3rd Draft Finished @@ -48,13 +48,13 @@ Main
- + Novel - + Title Page @@ -93,7 +93,19 @@ We Found John! - + + + Sequel + + + + Title Page + + + + Chapter One + + Characters @@ -109,7 +121,7 @@ Jane Smith - + Locations @@ -125,7 +137,7 @@ Mars - + Archive @@ -137,7 +149,7 @@ Old File - + Trash diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 78361bb6..3d40a9df 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -197,8 +197,7 @@ def testCoreIndex_CheckThese(mockGUI, fncDir, mockRnd): nItem = theProject.tree[nHandle] cItem = theProject.tree[cHandle] - assert theIndex.novelChangedSince(0) is False - assert theIndex.notesChangedSince(0) is False + assert theIndex.rootChangedSince("0000000000010", 0) is False assert theIndex.indexChangedSince(0) is False assert theIndex.scanText(cHandle, ( @@ -228,12 +227,14 @@ def testCoreIndex_CheckThese(mockGUI, fncDir, mockRnd): "@time": [] } - assert theIndex.novelChangedSince(0) is True - assert theIndex.notesChangedSince(0) is True + assert theIndex.rootChangedSince("0000000000010", 0) is True assert theIndex.indexChangedSince(0) is True assert theIndex.getHandleHeaderLevel(cHandle) == "H1" assert theIndex.getHandleHeaderLevel(nHandle) == "H1" + assert theIndex.getHandleHeaderIntLevel(cHandle) == 1 + assert theIndex.getHandleHeaderIntLevel(nHandle) == 1 + assert theIndex.getHandleHeaderIntLevel("stuff") == 0 # Zero Items assert theIndex.checkThese([], cItem) == [] diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index 12072e09..f21d5d78 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -25,7 +25,6 @@ import pytest from tools import readFile from novelwriter.core import NWProject, ToHtml -from novelwriter.core.index import NWIndex @pytest.mark.core @@ -33,7 +32,6 @@ def testCoreToHtml_ConvertFormat(mockGUI): """Test the tokenizer and converter chain using the ToHtml class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theHtml = ToHtml(theProject) # Novel Files Headers @@ -236,7 +234,6 @@ def testCoreToHtml_ConvertDirect(mockGUI): """Test the converter directly using the ToHtml class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theHtml = ToHtml(theProject) theHtml._isNovel = True @@ -607,7 +604,6 @@ def testCoreToHtml_Format(mockGUI): """Test all the formatters for the ToHtml class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theHtml = ToHtml(theProject) # Export Mode diff --git a/tests/test_core/test_core_tomd.py b/tests/test_core/test_core_tomd.py index c2235ff8..2e49d16b 100644 --- a/tests/test_core/test_core_tomd.py +++ b/tests/test_core/test_core_tomd.py @@ -25,7 +25,6 @@ import pytest from tools import readFile from novelwriter.core import NWProject, ToMarkdown -from novelwriter.core.index import NWIndex @pytest.mark.core @@ -33,7 +32,6 @@ def testCoreToMarkdown_ConvertFormat(mockGUI): """Test the tokenizer and converter chain using the ToMarkdown class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theMD = ToMarkdown(theProject) # Headers @@ -162,7 +160,6 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): """Test the converter directly using the ToMarkdown class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theMD = ToMarkdown(theProject) theMD._isNovel = True @@ -267,7 +264,6 @@ def testCoreToMarkdown_Format(mockGUI): """Test all the formatters for the ToMarkdown class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theMD = ToMarkdown(theProject) assert theMD._formatKeywords("", theMD.A_NONE) == "" diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index febbc94f..c714b5f5 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -29,7 +29,6 @@ from shutil import copyfile from tools import cmpFiles from novelwriter.core import NWProject, ToOdt -from novelwriter.core.index import NWIndex from novelwriter.core.toodt import ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag XML_NS = [ @@ -56,7 +55,6 @@ def testCoreToOdt_Init(mockGUI): """Test initialisation of the ODT document. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) # Flat Doc # ======== @@ -112,7 +110,6 @@ def testCoreToOdt_TextFormatting(mockGUI): """Test formatting of paragraphs. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc.initDocument() @@ -234,7 +231,6 @@ def testCoreToOdt_Convert(mockGUI): """Test the converter of the ToOdt class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True @@ -566,7 +562,6 @@ def testCoreToOdt_ConvertDirect(mockGUI): otherwise hard to reach conditions. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True @@ -621,7 +616,6 @@ def testCoreToOdt_SaveFlat(mockGUI, fncDir, outDir, refDir): """Test the document save functions. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True @@ -658,7 +652,6 @@ def testCoreToOdt_SaveFull(mockGUI, fncDir, outDir, refDir): """Test the document save functions. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=False) theDoc._isNovel = True @@ -738,7 +731,6 @@ def testCoreToOdt_Format(mockGUI): """Test the formatters for the ToOdt class. """ theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) theDoc = ToOdt(theProject, isFlat=True) assert theDoc._formatSynopsis("synopsis text") == ( diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 5c05f679..23eb7f73 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -149,6 +149,11 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert theTree.isTrash("a000000000003") is True assert theTree.isRoot("a000000000002") is True + # Check that we have the root classes + assert theTree.rootClasses() == { + nwItemClass.NOVEL, nwItemClass.CHARACTER, nwItemClass.ARCHIVE, nwItemClass.TRASH + } + # Check the isTrash function assert theTree.isTrash("0000000000000") is True # Doesn't exist assert theTree.isTrash("a000000000003") is True # This the trash folder diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 5e00815d..5f49d51d 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -61,7 +61,6 @@ def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): assert nwGUI.editItem() is False assert nwGUI.requestNovelTreeRefresh() is False assert nwGUI.rebuildIndex() is False - assert nwGUI.rebuildOutline() is False assert nwGUI.showProjectSettingsDialog() is False assert nwGUI.showProjectDetailsDialog() is False assert nwGUI.showBuildProjectDialog() is False @@ -149,12 +148,12 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): # Project Outline has focus nwGUI.switchFocus(nwWidget.OUTLINE) with monkeypatch.context() as mp: - mp.setattr(GuiOutline, "hasFocus", lambda *a: True) + mp.setattr(GuiOutline, "treeFocus", lambda *a: True) assert nwGUI.docEditor.docHandle() is None - actItem = nwGUI.projView.topLevelItem(0) + actItem = nwGUI.projView.outlineView.topLevelItem(0) chpItem = actItem.child(0) selItem = chpItem.child(0) - nwGUI.projView.setCurrentItem(selItem) + nwGUI.projView.outlineView.setCurrentItem(selItem) nwGUI._keyPressReturn() assert nwGUI.docEditor.docHandle() == sHandle assert nwGUI.closeDocument() is True diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 40c58388..49a86b16 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -19,20 +19,144 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import os +import time import pytest -from PyQt5.QtCore import Qt, QPoint -from PyQt5.QtWidgets import QAction, QTreeWidgetItem, QMessageBox +from tools import buildTestProject, writeFile -from novelwriter.enum import nwOutline +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget, QMessageBox, QAction -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.enum import nwItemClass, nwOutline, nwView @pytest.mark.gui -def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, fncDir): + """Test the outline view. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) + + # Create a project + prjDir = os.path.join(fncDir, "project") + buildTestProject(nwGUI, prjDir) + + nwGUI.rebuildIndex() + nwGUI._changeView(nwView.OUTLINE) + + outlineMain = nwGUI.projView + outlineView = outlineMain.outlineView + outlineData = outlineMain.outlineData + outlineMenu = outlineMain.outlineBar.mColumns + + # Toggle scrollbars + nwGUI.mainConf.hideVScroll = True + nwGUI.mainConf.hideHScroll = True + nwGUI.projView.initOutline() + assert outlineView.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert outlineView.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert outlineData.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert outlineData.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + + nwGUI.mainConf.hideVScroll = False + nwGUI.mainConf.hideHScroll = False + nwGUI.projView.initOutline() + assert outlineView.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert outlineView.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert outlineData.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert outlineData.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded + + # Check focus + with monkeypatch.context() as mp: + mp.setattr(QWidget, "hasFocus", lambda *a: True) + assert outlineMain.treeFocus() is True + + outlineMain.setTreeFocus() # Can't check. just ensures that it doesn't error + + # Option State + # ============ + pOptions = nwGUI.theProject.options + colNames = [h.name for h in nwOutline] + colItems = [h for h in nwOutline] + colWidth = {h: outlineView.DEF_WIDTH[h] for h in nwOutline} + colHidden = {h: outlineView.DEF_HIDDEN[h] for h in nwOutline} + + assert outlineView.topLevelItemCount() > 0 + + # Save header state not allowed + outlineView._lastBuild = 0 + outlineView._saveHeaderState() + assert pOptions.getValue("GuiOutline", "headerOrder", []) == [] + + # Allow saving header state + outlineView._lastBuild = time.time() + outlineView._saveHeaderState() + assert pOptions.getValue("GuiOutline", "headerOrder", []) == colNames + assert outlineView._treeOrder == colItems + assert outlineView._colWidth == colWidth + assert outlineView._colHidden == colHidden + + # Get default values + optItems = pOptions.getValue("GuiOutline", "headerOrder", []) + optWidth = pOptions.getValue("GuiOutline", "columnWidth", {}) + optHidden = pOptions.getValue("GuiOutline", "columnHidden", {}) + + # Add invalid column name + pOptions.setValue("GuiOutline", "headerOrder", optItems + ["blabla"]) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Add duplicate column name + pOptions.setValue("GuiOutline", "headerOrder", optItems + [optItems[-1]]) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Invalid column width data + pOptions.setValue("GuiOutline", "headerOrder", optItems) + pOptions.setValue("GuiOutline", "columnWidth", {"blabla": None}) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Invalid column width data + pOptions.setValue("GuiOutline", "headerOrder", optItems) + pOptions.setValue("GuiOutline", "columnWidth", optWidth) + pOptions.setValue("GuiOutline", "columnHidden", {"bloabla": None}) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Valid settings + pOptions.setValue("GuiOutline", "headerOrder", optItems) + pOptions.setValue("GuiOutline", "columnWidth", optWidth) + pOptions.setValue("GuiOutline", "columnHidden", optHidden) + outlineView._loadHeaderState() + assert outlineView._treeOrder == colItems + assert outlineView._colHidden == colHidden + + # Header Menu + # =========== + + # Trigger the menu entry for all hidden columns + for hItem in nwOutline: + if outlineView.DEF_HIDDEN[hItem]: + outlineMenu.actionMap[hItem].activate(QAction.Trigger) + + # Now no columns should be hidden + outlineView._saveHeaderState() + assert not any(pOptions.getValue("GuiOutline", "columnHidden", None).values()) + + # qtbot.stop() + +# END Test testGuiOutline_Main + + +@pytest.mark.gui +def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, nwLipsum): """Test the outline view. """ # Block message box @@ -43,73 +167,109 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum): nwGUI.mainConf.lastPath = nwLipsum nwGUI.rebuildIndex() - nwGUI.mainStack.setCurrentIndex(nwGUI.idxOutlineView) + nwGUI._changeView(nwView.OUTLINE) - assert nwGUI.projView.topLevelItemCount() > 0 + outlineMain = nwGUI.projView + outlineBar = outlineMain.outlineBar + outlineView = outlineMain.outlineView + outlineData = outlineMain.outlineData - # Context Menu - nwGUI.projView._headerRightClick(QPoint(1, 1)) - nwGUI.projView.headerMenu.actionMap[nwOutline.CCOUNT].activate(QAction.Trigger) - nwGUI.projView.headerMenu.close() - qtbot.mouseClick(nwGUI.projView, Qt.LeftButton) + lipHandle = "b3643d0f92e32" - nwGUI.projView._loadHeaderState() - assert not nwGUI.projView._colHidden[nwOutline.CCOUNT] + # Check defaults in dropdown list + assert outlineBar.novelValue.itemData(0) == lipHandle + assert outlineBar.novelValue.itemData(1) is None # Separator + assert outlineBar.novelValue.itemData(2) == "" # All novels + + # Add a second novel folder + newHandle = nwGUI.theProject.newRoot(nwItemClass.NOVEL) + nwGUI.treeView.revealNewTreeItem(newHandle) + + # Check new values in dropdown list + assert outlineBar.novelValue.itemData(0) == lipHandle + assert outlineBar.novelValue.itemData(1) == newHandle + assert outlineBar.novelValue.itemData(2) is None # Separator + assert outlineBar.novelValue.itemData(3) == "" # All novels + + # Add a bunch of files in a header order that hits all tree combos + docList = [ + ("Section 1", 4), ("Scene 1", 3), ("Chapter 1", 2), ("Part 1", 1), + ("Section 2", 4), ("Scene 2", 3), ("Chapter 2", 2), + ("Section 3", 4), ("Scene 3", 3), + ("Section 4", 4), + ] + for dTitle, hLevel in docList: + aHandle = nwGUI.theProject.newFile(dTitle, newHandle) + hHash = "#"*hLevel + writeFile(os.path.join(nwLipsum, "content", f"{aHandle}.nwd"), f"{hHash} {dTitle}\n\n") + nwGUI.treeView.revealNewTreeItem(aHandle) + + nwGUI.rebuildIndex() + + # Build the second novel + outlineBar.novelValue.setCurrentIndex(1) + outlineBar._refreshRequested() + + # Go back to Lipsum + outlineBar.novelValue.setCurrentIndex(0) + outlineBar._refreshRequested() + + # Check Details + # ============= # First Item - nwGUI.rebuildOutline() - selItem = nwGUI.projView.topLevelItem(0) - assert isinstance(selItem, QTreeWidgetItem) + outlineView.refreshTree() + selItem = outlineView.topLevelItem(0) - nwGUI.projView.setCurrentItem(selItem) - assert nwGUI.projMeta.titleLabel.text() == "Title" - assert nwGUI.projMeta.titleValue.text() == "Lorem Ipsum" - assert nwGUI.projMeta.fileValue.text() == "Lorem Ipsum" - assert nwGUI.projMeta.itemValue.text() == "Finished" + outlineView.setCurrentItem(selItem) + assert outlineData.titleLabel.text() == "Title" + assert outlineData.titleValue.text() == "Lorem Ipsum" + assert outlineData.fileValue.text() == "Lorem Ipsum" + assert outlineData.itemValue.text() == "Finished" - assert nwGUI.projMeta.cCValue.text() == "230" - assert nwGUI.projMeta.wCValue.text() == "40" - assert nwGUI.projMeta.pCValue.text() == "3" + assert outlineData.cCValue.text() == "230" + assert outlineData.wCValue.text() == "40" + assert outlineData.pCValue.text() == "3" # Scene One - actItem = nwGUI.projView.topLevelItem(1) + actItem = outlineView.topLevelItem(1) chpItem = actItem.child(0) selItem = chpItem.child(0) - nwGUI.projView.setCurrentItem(selItem) - tHandle, tLine = nwGUI.projView.getSelectedHandle() + outlineView.setCurrentItem(selItem) + tHandle, tLine = outlineView.getSelectedHandle() assert tHandle == "88243afbe5ed8" assert tLine == 0 - assert nwGUI.projMeta.titleLabel.text() == "Scene" - assert nwGUI.projMeta.titleValue.text() == "Scene One" - assert nwGUI.projMeta.fileValue.text() == "Scene One" - assert nwGUI.projMeta.itemValue.text() == "Finished" + assert outlineData.titleLabel.text() == "Scene" + assert outlineData.titleValue.text() == "Scene One" + assert outlineData.fileValue.text() == "Scene One" + assert outlineData.itemValue.text() == "Finished" # Click POV Link - assert nwGUI.projMeta.povKeyValue.text() == "Bod" - nwGUI.projMeta._tagClicked("#pov=Bod") + assert outlineData.povKeyValue.text() == "Bod" + nwGUI.projView._tagClicked("Bod") assert nwGUI.docViewer.docHandle() == "4c4f28287af27" # Scene One, Section Two - actItem = nwGUI.projView.topLevelItem(1) + actItem = outlineView.topLevelItem(1) chpItem = actItem.child(0) scnItem = chpItem.child(0) selItem = scnItem.child(0) - nwGUI.projView.setCurrentItem(selItem) - tHandle, tLine = nwGUI.projView.getSelectedHandle() + outlineView.setCurrentItem(selItem) + tHandle, tLine = outlineView.getSelectedHandle() assert tHandle == "88243afbe5ed8" assert tLine == 12 - assert nwGUI.projMeta.titleLabel.text() == "Section" - assert nwGUI.projMeta.titleValue.text() == "Scene One, Section Two" - assert nwGUI.projMeta.fileValue.text() == "Scene One" - assert nwGUI.projMeta.itemValue.text() == "Finished" + assert outlineData.titleLabel.text() == "Section" + assert outlineData.titleValue.text() == "Scene One, Section Two" + assert outlineData.fileValue.text() == "Scene One" + assert outlineData.itemValue.text() == "Finished" - nwGUI.projView._treeDoubleClick(selItem, 0) + outlineView._treeDoubleClick(selItem, 0) assert nwGUI.docEditor.docHandle() == "88243afbe5ed8" - # qtbot.stopForInteraction() + # qtbot.stop() -# END Test testGuiOutline_Main +# END Test testGuiOutline_Content diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 39e0b4dc..258ad150 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -96,7 +96,7 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockRnd) assert nwGUI.theProject.tree["0000000000013"].itemRoot == "0000000000008" assert nwGUI.theProject.tree["0000000000013"].itemClass == nwItemClass.NOVEL assert nwGUI.openDocument("0000000000013") - assert nwGUI.docEditor.getText() == "### New Document\n\n" + assert nwGUI.docEditor.getText() == "## New Document\n\n" # Add a new file to the characters folder nwTree.setSelectedHandle("000000000000a") @@ -246,17 +246,14 @@ def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, fncDir, mockRnd): # Move novel folder up assert nwTree.moveTreeItem(-1) is False - nwTree.flushTreeOrder() assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 # Move novel folder down assert nwTree.moveTreeItem(1) is True - nwTree.flushTreeOrder() assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 1 # Move novel folder up again assert nwTree.moveTreeItem(-1) is True - nwTree.flushTreeOrder() assert nwGUI.theProject.tree._treeOrder.index("0000000000008") == 0 # Clean up @@ -432,10 +429,8 @@ def testGuiProjTree_DeleteItems(qtbot, caplog, monkeypatch, nwGUI, fncDir, mockR assert nwTree.emptyTrash() is False # Empty the trash proper - nwTree._setTreeChanged(False) assert nwTree.emptyTrash() is True assert nwTree.getTreeFromHandle(trashHandle) == [trashHandle] - assert nwTree._treeChanged is True # Try to delete a file, but block the underlying deletion of the file on disk assert os.path.isfile(os.path.join(fncDir, "project", "content", "000000000000e.nwd"))