diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41144367..c22116af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
## Version 1.1 Dev (Alpha)
+### Release Notes
+
+### Detailed Changelog
+
+**User Interface**
+
+* Added a Novel tab under the project tree where the user can navigate the novel's layout of
+ chapters and scenes, similar to the Outline view, but next to the document editor. The Outline
+ view and Novel/Project trees now also behave more in cooperation. When files on one are selected
+ or moved, the other will follow and update. PR #537.
+
----
## Version 1.0 [2021-01-03]
diff --git a/nw/config.py b/nw/config.py
index ddec796f..e533b5b2 100644
--- a/nw/config.py
+++ b/nw/config.py
@@ -95,14 +95,15 @@ class Config:
self.lastNotes = "" # The latest release notes that have been shown
## Sizes
- self.winGeometry = [1200, 650]
- self.treeColWidth = [200, 50, 30]
- self.projColWidth = [200, 60, 140]
- self.mainPanePos = [300, 800]
- self.docPanePos = [400, 400]
- self.viewPanePos = [500, 150]
- self.outlnPanePos = [500, 150]
- self.isFullScreen = False
+ self.winGeometry = [1200, 650]
+ self.treeColWidth = [200, 50, 30]
+ self.novelColWidth = [200, 50]
+ self.projColWidth = [200, 60, 140]
+ self.mainPanePos = [300, 800]
+ self.docPanePos = [400, 400]
+ self.viewPanePos = [500, 150]
+ self.outlnPanePos = [500, 150]
+ self.isFullScreen = False
## Features
self.hideVScroll = False # Hide vertical scroll bars on main widgets
@@ -395,6 +396,9 @@ class Config:
self.treeColWidth = self._parseLine(
cnfParse, cnfSec, "treecols", self.CNF_I_LST, self.treeColWidth
)
+ self.novelColWidth = self._parseLine(
+ cnfParse, cnfSec, "novelcols", self.CNF_I_LST, self.novelColWidth
+ )
self.projColWidth = self._parseLine(
cnfParse, cnfSec, "projcols", self.CNF_I_LST, self.projColWidth
)
@@ -597,6 +601,7 @@ class Config:
cnfParse.add_section(cnfSec)
cnfParse.set(cnfSec, "geometry", self._packList(self.winGeometry))
cnfParse.set(cnfSec, "treecols", self._packList(self.treeColWidth))
+ cnfParse.set(cnfSec, "novelcols", self._packList(self.novelColWidth))
cnfParse.set(cnfSec, "projcols", self._packList(self.projColWidth))
cnfParse.set(cnfSec, "mainpane", self._packList(self.mainPanePos))
cnfParse.set(cnfSec, "docpane", self._packList(self.docPanePos))
@@ -816,6 +821,11 @@ class Config:
self.confChanged = True
return True
+ def setNovelColWidths(self, colWidths):
+ self.novelColWidth = [int(x/self.guiScale) for x in colWidths]
+ self.confChanged = True
+ return True
+
def setProjColWidths(self, colWidths):
self.projColWidth = [int(x/self.guiScale) for x in colWidths]
self.confChanged = True
@@ -872,6 +882,9 @@ class Config:
def getTreeColWidths(self):
return [int(x*self.guiScale) for x in self.treeColWidth]
+ def getNovelColWidths(self):
+ return [int(x*self.guiScale) for x in self.novelColWidth]
+
def getProjColWidths(self):
return [int(x*self.guiScale) for x in self.projColWidth]
diff --git a/nw/core/index.py b/nw/core/index.py
index f94bbf3e..c311b1be 100644
--- a/nw/core/index.py
+++ b/nw/core/index.py
@@ -51,18 +51,16 @@ class NWIndex():
self.indexBroken = False
# Indices
- self.tagIndex = None
- self.refIndex = None
- self.novelIndex = None
- self.noteIndex = None
- self.textCounts = None
+ self._tagIndex = {}
+ self._refIndex = {}
+ self._novelIndex = {}
+ self._noteIndex = {}
+ self._textCounts = {}
# TimeStamps
- self.timeNovel = 0
- self.timeNote = 0
- self.timeIndex = 0
-
- self.clearIndex()
+ self._timeNovel = 0
+ self._timeNotes = 0
+ self._timeIndex = 0
return
@@ -73,14 +71,14 @@ class NWIndex():
def clearIndex(self):
"""Clear the index dictionaries and time stamps.
"""
- self.tagIndex = {}
- self.refIndex = {}
- self.novelIndex = {}
- self.noteIndex = {}
- self.textCounts = {}
- self.timeNovel = 0
- self.timeNote = 0
- self.timeIndex = 0
+ self._tagIndex = {}
+ self._refIndex = {}
+ self._novelIndex = {}
+ self._noteIndex = {}
+ self._textCounts = {}
+ self._timeNovel = 0
+ self._timeNotes = 0
+ self._timeIndex = 0
return
def deleteHandle(self, tHandle):
@@ -89,17 +87,17 @@ class NWIndex():
logger.debug("Removing item %s from the index" % tHandle)
delTags = []
- for tTag in self.tagIndex:
- if self.tagIndex[tTag][1] == tHandle:
+ for tTag in self._tagIndex:
+ if self._tagIndex[tTag][1] == tHandle:
delTags.append(tTag)
for tTag in delTags:
- self.tagIndex.pop(tTag, None)
+ self._tagIndex.pop(tTag, None)
- self.refIndex.pop(tHandle, None)
- self.novelIndex.pop(tHandle, None)
- self.noteIndex.pop(tHandle, None)
- self.textCounts.pop(tHandle, None)
+ self._refIndex.pop(tHandle, None)
+ self._novelIndex.pop(tHandle, None)
+ self._noteIndex.pop(tHandle, None)
+ self._textCounts.pop(tHandle, None)
return
@@ -123,6 +121,21 @@ class NWIndex():
return True
+ def novelChangedSince(self, checkTime):
+ """Check if the novel index has changed since a given time.
+ """
+ return self._timeNovel > checkTime
+
+ def notesChangedSince(self, checkTime):
+ """Check if the notes index has changed since a given time.
+ """
+ return self._timeNotes > checkTime
+
+ def indexChangedSince(self, checkTime):
+ """Check if the index has changed since a given time.
+ """
+ return self._timeIndex > checkTime
+
##
# Load and Save Index to/from File
##
@@ -143,16 +156,16 @@ class NWIndex():
logger.error(str(e))
return False
- self.tagIndex = theData.get("tagIndex", {})
- self.refIndex = theData.get("refIndex", {})
- self.novelIndex = theData.get("novelIndex", {})
- self.noteIndex = theData.get("noteIndex", {})
- self.textCounts = theData.get("textCounts", {})
+ self._tagIndex = theData.get("tagIndex", {})
+ self._refIndex = theData.get("refIndex", {})
+ self._novelIndex = theData.get("novelIndex", {})
+ self._noteIndex = theData.get("noteIndex", {})
+ self._textCounts = theData.get("textCounts", {})
nowTime = round(time())
- self.timeNovel = nowTime
- self.timeNote = nowTime
- self.timeIndex = nowTime
+ self._timeNovel = nowTime
+ self._timeNotes = nowTime
+ self._timeIndex = nowTime
self.checkIndex()
@@ -168,11 +181,11 @@ class NWIndex():
try:
with open(indexFile, mode="w+", encoding="utf8") as outFile:
json.dump({
- "tagIndex" : self.tagIndex,
- "refIndex" : self.refIndex,
- "novelIndex" : self.novelIndex,
- "noteIndex" : self.noteIndex,
- "textCounts" : self.textCounts,
+ "tagIndex" : self._tagIndex,
+ "refIndex" : self._refIndex,
+ "novelIndex" : self._novelIndex,
+ "noteIndex" : self._noteIndex,
+ "textCounts" : self._textCounts,
}, outFile, indent=2)
except Exception as e:
logger.error("Failed to save index file")
@@ -189,28 +202,28 @@ class NWIndex():
self.indexBroken = False
try:
- for tTag in self.tagIndex:
- if len(self.tagIndex[tTag]) != 4:
+ for tTag in self._tagIndex:
+ if len(self._tagIndex[tTag]) != 4:
self.indexBroken = True
- for tHandle in self.refIndex:
- for sTitle in self.refIndex[tHandle]:
- for tEntry in self.refIndex[tHandle][sTitle]["tags"]:
+ for tHandle in self._refIndex:
+ for sTitle in self._refIndex[tHandle]:
+ for tEntry in self._refIndex[tHandle][sTitle]["tags"]:
if len(tEntry) != 3:
self.indexBroken = True
- for tHandle in self.novelIndex:
- for sLine in self.novelIndex[tHandle]:
- if len(self.novelIndex[tHandle][sLine].keys()) != 8:
+ for tHandle in self._novelIndex:
+ for sLine in self._novelIndex[tHandle]:
+ if len(self._novelIndex[tHandle][sLine].keys()) != 8:
self.indexBroken = True
- for tHandle in self.noteIndex:
- for sLine in self.noteIndex[tHandle]:
- if len(self.noteIndex[tHandle][sLine].keys()) != 8:
+ for tHandle in self._noteIndex:
+ for sLine in self._noteIndex[tHandle]:
+ if len(self._noteIndex[tHandle][sLine].keys()) != 8:
self.indexBroken = True
- for tHandle in self.textCounts:
- if len(self.textCounts[tHandle]) != 3:
+ for tHandle in self._textCounts:
+ if len(self._textCounts[tHandle]) != 3:
self.indexBroken = True
except Exception:
@@ -254,7 +267,7 @@ class NWIndex():
# Run word counter for the whole text
cC, wC, pC = countWords(theText)
- self.textCounts[tHandle] = [cC, wC, pC]
+ self._textCounts[tHandle] = [cC, wC, pC]
# If the file is archived or trashed, we don't index the file itself
if self.theProject.projTree.isTrashRoot(theItem.itemParent):
@@ -271,25 +284,25 @@ class NWIndex():
# Check file type, and reset its old index
# Also add a dummy entry T000000 in case the file has no title
- self.refIndex[tHandle] = {}
- self.refIndex[tHandle]["T000000"] = {
+ self._refIndex[tHandle] = {}
+ self._refIndex[tHandle]["T000000"] = {
"tags" : [],
"updated" : round(time()),
}
if itemLayout == nwItemLayout.NOTE:
- self.noteIndex[tHandle] = {}
+ self._noteIndex[tHandle] = {}
isNovel = False
else:
- self.novelIndex[tHandle] = {}
+ self._novelIndex[tHandle] = {}
isNovel = True
# Also clear references to file in tag index
clearTags = []
- for aTag in self.tagIndex:
- if self.tagIndex[aTag][1] == tHandle:
+ for aTag in self._tagIndex:
+ if self._tagIndex[aTag][1] == tHandle:
clearTags.append(aTag)
for aTag in clearTags:
- self.tagIndex.pop(aTag)
+ self._tagIndex.pop(aTag)
nLine = 0
nTitle = 0
@@ -330,11 +343,11 @@ class NWIndex():
# Update timestamps for index changes
nowTime = round(time())
- self.timeIndex = nowTime
+ self._timeIndex = nowTime
if isNovel:
- self.timeNovel = nowTime
+ self._timeNovel = nowTime
else:
- self.timeNote = nowTime
+ self._timeNotes = nowTime
return True
@@ -362,7 +375,7 @@ class NWIndex():
return False
sTitle = "T%06d" % nLine
- self.refIndex[tHandle][sTitle] = {
+ self._refIndex[tHandle][sTitle] = {
"tags" : [],
"updated" : round(time()),
}
@@ -379,11 +392,11 @@ class NWIndex():
if hText != "":
if isNovel:
- if tHandle in self.novelIndex:
- self.novelIndex[tHandle][sTitle] = theData
+ if tHandle in self._novelIndex:
+ self._novelIndex[tHandle][sTitle] = theData
else:
- if tHandle in self.noteIndex:
- self.noteIndex[tHandle][sTitle] = theData
+ if tHandle in self._noteIndex:
+ self._noteIndex[tHandle][sTitle] = theData
return True
@@ -393,19 +406,19 @@ class NWIndex():
cC, wC, pC = countWords(theText)
sTitle = "T%06d" % nTitle
if isNovel:
- if tHandle in self.novelIndex:
- if sTitle in self.novelIndex[tHandle]:
- self.novelIndex[tHandle][sTitle]["cCount"] = cC
- self.novelIndex[tHandle][sTitle]["wCount"] = wC
- self.novelIndex[tHandle][sTitle]["pCount"] = pC
- self.novelIndex[tHandle][sTitle]["updated"] = round(time())
+ if tHandle in self._novelIndex:
+ if sTitle in self._novelIndex[tHandle]:
+ self._novelIndex[tHandle][sTitle]["cCount"] = cC
+ self._novelIndex[tHandle][sTitle]["wCount"] = wC
+ self._novelIndex[tHandle][sTitle]["pCount"] = pC
+ self._novelIndex[tHandle][sTitle]["updated"] = round(time())
else:
- if tHandle in self.noteIndex:
- if sTitle in self.noteIndex[tHandle]:
- self.noteIndex[tHandle][sTitle]["cCount"] = cC
- self.noteIndex[tHandle][sTitle]["wCount"] = wC
- self.noteIndex[tHandle][sTitle]["pCount"] = pC
- self.noteIndex[tHandle][sTitle]["updated"] = round(time())
+ if tHandle in self._noteIndex:
+ if sTitle in self._noteIndex[tHandle]:
+ self._noteIndex[tHandle][sTitle]["cCount"] = cC
+ self._noteIndex[tHandle][sTitle]["wCount"] = wC
+ self._noteIndex[tHandle][sTitle]["pCount"] = pC
+ self._noteIndex[tHandle][sTitle]["updated"] = round(time())
return
def _indexSynopsis(self, tHandle, isNovel, theText, nTitle):
@@ -413,15 +426,15 @@ class NWIndex():
"""
sTitle = "T%06d" % nTitle
if isNovel:
- if tHandle in self.novelIndex:
- if sTitle in self.novelIndex[tHandle]:
- self.novelIndex[tHandle][sTitle]["synopsis"] = theText
- self.novelIndex[tHandle][sTitle]["updated"] = round(time())
+ if tHandle in self._novelIndex:
+ if sTitle in self._novelIndex[tHandle]:
+ self._novelIndex[tHandle][sTitle]["synopsis"] = theText
+ self._novelIndex[tHandle][sTitle]["updated"] = round(time())
else:
- if tHandle in self.noteIndex:
- if sTitle in self.noteIndex[tHandle]:
- self.noteIndex[tHandle][sTitle]["synopsis"] = theText
- self.noteIndex[tHandle][sTitle]["updated"] = round(time())
+ if tHandle in self._noteIndex:
+ if sTitle in self._noteIndex[tHandle]:
+ self._noteIndex[tHandle][sTitle]["synopsis"] = theText
+ self._noteIndex[tHandle][sTitle]["updated"] = round(time())
return
def _indexNoteRef(self, tHandle, aLine, nLine, nTitle):
@@ -433,9 +446,9 @@ class NWIndex():
return False
sTitle = "T%06d" % nTitle
- if sTitle in self.refIndex[tHandle] and theBits[0] != nwKeyWords.TAG_KEY:
+ if sTitle in self._refIndex[tHandle] and theBits[0] != nwKeyWords.TAG_KEY:
for aVal in theBits[1:]:
- self.refIndex[tHandle][sTitle]["tags"].append([nLine, theBits[0], aVal])
+ self._refIndex[tHandle][sTitle]["tags"].append([nLine, theBits[0], aVal])
return True
@@ -448,7 +461,7 @@ class NWIndex():
if theBits[0] == nwKeyWords.TAG_KEY:
sTitle = "T%06d" % nTitle
- self.tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle]
+ self._tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle]
return True
@@ -512,8 +525,8 @@ class NWIndex():
# is ignored
if theBits[0] == nwKeyWords.TAG_KEY and nBits > 1:
isGood[0] = True
- if theBits[1] in self.tagIndex:
- if self.tagIndex[theBits[1]][1] == tItem.itemHandle:
+ if theBits[1] in self._tagIndex:
+ if self._tagIndex[theBits[1]][1] == tItem.itemHandle:
isGood[1] = True
else:
isGood[1] = False
@@ -523,8 +536,8 @@ class NWIndex():
# If we're still here, we better check that the references exist
for n in range(1, nBits):
- if theBits[n] in self.tagIndex:
- isGood[n] = nwKeyWords.KEY_CLASS[theBits[0]].name == self.tagIndex[theBits[n]][2]
+ if theBits[n] in self._tagIndex:
+ isGood[n] = nwKeyWords.KEY_CLASS[theBits[0]].name == self._tagIndex[theBits[n]][2]
return isGood
@@ -532,23 +545,21 @@ class NWIndex():
# Extract Data
##
- def getNovelStructure(self, skipExcluded=True):
- """Builds a list of all titles in the novel, in the correct
- order as they appear in the tree view and in the respective
- document files, but skipping all note files.
+ def novelStructure(self, skipExcluded=True):
+ """Iterate over all titles in the novel, in the correct order as
+ they appear in the tree view and in the respective document
+ files, but skipping all note files.
"""
- theStructure = []
for tItem in self.theProject.projTree:
if tItem is not None:
if not tItem.isExported and skipExcluded:
continue
tHandle = tItem.itemHandle
- if tHandle not in self.novelIndex:
+ if tHandle not in self._novelIndex:
continue
- for sTitle in sorted(self.novelIndex[tHandle].keys()):
- theStructure.append("%s:%s" % (tHandle, sTitle))
-
- return theStructure
+ for sTitle in sorted(self._novelIndex[tHandle]):
+ tKey = "%s:%s" % (tHandle, sTitle)
+ yield tKey, tHandle, sTitle, self._novelIndex[tHandle][sTitle]
def getCounts(self, tHandle, sTitle=None):
"""Returns the counts for a file, or a section of a file
@@ -559,21 +570,21 @@ class NWIndex():
pC = 0
if sTitle is None:
- if tHandle in self.textCounts:
- cC = self.textCounts[tHandle][0]
- wC = self.textCounts[tHandle][1]
- pC = self.textCounts[tHandle][2]
+ if tHandle in self._textCounts:
+ cC = self._textCounts[tHandle][0]
+ wC = self._textCounts[tHandle][1]
+ pC = self._textCounts[tHandle][2]
else:
- if tHandle in self.novelIndex:
- if sTitle in self.novelIndex[tHandle]:
- cC = self.novelIndex[tHandle][sTitle]["cCount"]
- wC = self.novelIndex[tHandle][sTitle]["wCount"]
- pC = self.novelIndex[tHandle][sTitle]["pCount"]
- elif tHandle in self.noteIndex:
- if sTitle in self.noteIndex[tHandle]:
- cC = self.noteIndex[tHandle][sTitle]["cCount"]
- wC = self.noteIndex[tHandle][sTitle]["wCount"]
- pC = self.noteIndex[tHandle][sTitle]["pCount"]
+ if tHandle in self._novelIndex:
+ if sTitle in self._novelIndex[tHandle]:
+ cC = self._novelIndex[tHandle][sTitle]["cCount"]
+ wC = self._novelIndex[tHandle][sTitle]["wCount"]
+ pC = self._novelIndex[tHandle][sTitle]["pCount"]
+ elif tHandle in self._noteIndex:
+ if sTitle in self._noteIndex[tHandle]:
+ cC = self._noteIndex[tHandle][sTitle]["cCount"]
+ wC = self._noteIndex[tHandle][sTitle]["wCount"]
+ pC = self._noteIndex[tHandle][sTitle]["pCount"]
return cC, wC, pC
@@ -585,16 +596,24 @@ class NWIndex():
for tKey in nwKeyWords.KEY_CLASS:
theRefs[tKey] = []
- if tHandle not in self.refIndex:
+ if tHandle not in self._refIndex:
return theRefs
- for refTitle in self.refIndex[tHandle]:
- for aTag in self.refIndex[tHandle][refTitle].get("tags", []):
+ for refTitle in self._refIndex[tHandle]:
+ for aTag in self._refIndex[tHandle][refTitle].get("tags", []):
if len(aTag) == 3 and (sTitle is None or sTitle == refTitle):
theRefs[aTag[1]].append(aTag[2])
return theRefs
+ def getNovelData(self, tHandle, sTitle):
+ """Return the novel data of a given handle and title.
+ """
+ if tHandle in self._novelIndex:
+ if sTitle in self._novelIndex[tHandle]:
+ return self._novelIndex[tHandle][sTitle]
+ return None
+
def getBackReferenceList(self, tHandle):
"""Build a list of files referring back to our file, specified
by tHandle.
@@ -604,14 +623,14 @@ class NWIndex():
return theRefs
theTags = set()
- for tTag in self.tagIndex:
- if tHandle == self.tagIndex[tTag][1]:
+ for tTag in self._tagIndex:
+ if tHandle == self._tagIndex[tTag][1]:
theTags.add(tTag)
if theTags:
- for tHandle in self.refIndex:
- for sTitle in self.refIndex[tHandle]:
- for _, _, tTag in self.refIndex[tHandle][sTitle]["tags"]:
+ for tHandle in self._refIndex:
+ for sTitle in self._refIndex[tHandle]:
+ for _, _, tTag in self._refIndex[tHandle][sTitle]["tags"]:
if tTag in theTags and tHandle not in theRefs:
theRefs[tHandle] = sTitle
@@ -620,8 +639,8 @@ class NWIndex():
def getTagSource(self, theTag):
"""Return the source location of a given tag.
"""
- if theTag in self.tagIndex:
- theRef = self.tagIndex[theTag]
+ if theTag in self._tagIndex:
+ theRef = self._tagIndex[theTag]
if len(theRef) == 4:
return theRef[1], theRef[0], theRef[3]
return None, 0, "T000000"
diff --git a/nw/core/project.py b/nw/core/project.py
index d6b6da78..204728cc 100644
--- a/nw/core/project.py
+++ b/nw/core/project.py
@@ -1025,7 +1025,7 @@ class NWProject():
by drag-and-drop. Forwarded to the NWTree class.
"""
if len(self.projTree) != len(newOrder):
- logger.warning("Size of new and old tree order do not match")
+ logger.warning("Sizes of new and old tree order do not match")
self.projTree.setOrder(newOrder)
self.setProjectChanged(True)
return True
@@ -1341,7 +1341,7 @@ class NWProject():
if oLayout is None:
oLayout = nwItemLayout.NOTE
- if oParent is None or not self.projTree.handleExists(oParent):
+ if oParent is None or oParent not in self.projTree:
oParent = self.projTree.findRoot(oClass)
if oParent is None:
oParent = self.projTree.findRoot(nwItemClass.NOVEL)
diff --git a/nw/core/tree.py b/nw/core/tree.py
index d559d91b..51b97c7e 100644
--- a/nw/core/tree.py
+++ b/nw/core/tree.py
@@ -294,11 +294,6 @@ class NWTree():
tTree.append(tHandle)
return tTree
- def handleExists(self, tHandle):
- """Check if a handle exists in the project.
- """
- return tHandle in self._treeOrder
-
##
# Setters
##
diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py
index d40d84fc..6b71867d 100644
--- a/nw/gui/__init__.py
+++ b/nw/gui/__init__.py
@@ -9,6 +9,7 @@ from nw.gui.docviewer import GuiDocViewer, GuiDocViewDetails
from nw.gui.itemdetails import GuiItemDetails
from nw.gui.itemeditor import GuiItemEditor
from nw.gui.mainmenu import GuiMainMenu
+from nw.gui.noveltree import GuiNovelTree
from nw.gui.outline import GuiOutline
from nw.gui.outlinedetails import GuiOutlineDetails
from nw.gui.preferences import GuiPreferences
@@ -31,6 +32,7 @@ __all__ = [
"GuiItemDetails",
"GuiItemEditor",
"GuiMainMenu",
+ "GuiNovelTree",
"GuiMainStatus",
"GuiOutline",
"GuiOutlineDetails",
diff --git a/nw/gui/noveltree.py b/nw/gui/noveltree.py
new file mode 100644
index 00000000..1174bbfe
--- /dev/null
+++ b/nw/gui/noveltree.py
@@ -0,0 +1,312 @@
+# -*- coding: utf-8 -*-
+"""novelWriter GUI Novel Tree
+
+ novelWriter – GUI Novel Tree
+==============================
+ Class holding the project's novel files tree view
+
+ File History:
+ Created: 2020-12-20 [1.1a0]
+
+ This file is a part of novelWriter
+ Copyright 2018–2020, Veronica Berglyd Olsen
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+"""
+
+import nw
+import logging
+
+from time import time
+
+from PyQt5.QtCore import Qt, QSize
+from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView
+
+from nw.constants import nwKeyWords
+from nw.common import checkInt
+
+logger = logging.getLogger(__name__)
+
+class GuiNovelTree(QTreeWidget):
+
+ C_TITLE = 0
+ C_WORDS = 1
+ C_POV = 2
+
+ def __init__(self, theParent):
+ QTreeWidget.__init__(self, theParent)
+
+ logger.debug("Initialising GuiNovelTree ...")
+
+ self.mainConf = nw.CONFIG
+ self.theParent = theParent
+ self.theTheme = theParent.theTheme
+ self.theProject = theParent.theProject
+ self.theIndex = theParent.theIndex
+
+ # Internal Variables
+ self._treeMap = {}
+ self._lastBuild = 0
+
+ # Build GUI
+ iPx = self.theTheme.baseIconSize
+ self.setIconSize(QSize(iPx, iPx))
+ self.setIndentation(iPx)
+ self.setColumnCount(3)
+ self.setHeaderLabels(["Title", "Words", "POV"])
+ self.itemDoubleClicked.connect(self._treeDoubleClick)
+ self.itemSelectionChanged.connect(self._itemSelected)
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.setSelectionMode(QAbstractItemView.SingleSelection)
+ self.setExpandsOnDoubleClick(False)
+ self.setDragEnabled(False)
+
+ treeHeadItem = self.headerItem()
+ treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
+ treeHeadItem.setToolTip(self.C_TITLE, "Section title")
+ treeHeadItem.setToolTip(self.C_WORDS, "Word count")
+ treeHeadItem.setToolTip(self.C_POV, "Point-of-view character")
+
+ treeHeader = self.header()
+ treeHeader.setStretchLastSection(True)
+ treeHeader.setMinimumSectionSize(iPx + 6)
+
+ # Get user's column width preferences for NAME and COUNT
+ treeColWidth = self.mainConf.getNovelColWidths()
+ if len(treeColWidth) <= 3:
+ for colN, colW in enumerate(treeColWidth):
+ self.setColumnWidth(colN, colW)
+
+ # The last column should just auto-scale
+ self.resizeColumnToContents(self.C_POV)
+
+ # Set custom settings
+ self.initTree()
+
+ logger.debug("GuiNovelTree initialisation complete")
+
+ # Internal Mapping
+ self.makeAlert = self.theParent.makeAlert
+
+ return
+
+ def initTree(self):
+ """Set or update tree widget settings.
+ """
+ # Scroll bars
+ if self.mainConf.hideVScroll:
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ else:
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+
+ if self.mainConf.hideHScroll:
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ else:
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+
+ return
+
+ ##
+ # Class Methods
+ ##
+
+ def clearTree(self):
+ """Clear the GUI content and the related maps.
+ """
+ self.clear()
+ self._treeMap = {}
+ self._lastBuild = 0
+ return
+
+ def refreshTree(self, overRide=False):
+ """Called whenever the Novel tab is activated.
+ """
+ treeChanged = self.theParent.treeView.changedSince(self._lastBuild)
+ indexChanged = self.theIndex.novelChangedSince(self._lastBuild)
+ if not (treeChanged or indexChanged):
+ logger.verbose("No changes made to the novel")
+ return
+
+ selItem = self.selectedItems()
+ titleKey = None
+ if selItem:
+ titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2]
+
+ self.theParent.treeView.flushTreeOrder()
+ self._populateTree()
+
+ if titleKey is not None and titleKey in self._treeMap:
+ self._treeMap[titleKey].setSelected(True)
+
+ return
+
+ def getColumnSizes(self):
+ """Return the column widths for the tree columns.
+ """
+ retVals = [
+ self.columnWidth(0),
+ self.columnWidth(1),
+ ]
+ return retVals
+
+ def getSelectedHandle(self):
+ """Get the currently selected handle. If multiple items are
+ selected, return the first.
+ """
+ selItem = self.selectedItems()
+ if selItem:
+ return selItem[0].data(self.C_TITLE, Qt.UserRole)[0]
+
+ return None
+
+ ##
+ # Events
+ ##
+
+ def mousePressEvent(self, theEvent):
+ """Overload mousePressEvent to clear selection if clicking the
+ mouse in a blank area of the tree view, and to load a document
+ for viewing if the user middle-clicked.
+ """
+ QTreeWidget.mousePressEvent(self, theEvent)
+
+ if theEvent.button() == Qt.LeftButton:
+ selItem = self.indexAt(theEvent.pos())
+ if not selItem.isValid():
+ self.clearSelection()
+
+ elif theEvent.button() == Qt.MiddleButton:
+ selItem = self.itemAt(theEvent.pos())
+ if not isinstance(selItem, QTreeWidgetItem):
+ return
+
+ tHandle = self.getSelectedHandle()
+ if tHandle is None:
+ return
+
+ self.theParent.viewDocument(tHandle)
+
+ return
+
+ ##
+ # Slots
+ ##
+
+ def _treeDoubleClick(self, tItem, tCol):
+ """Extract the handle and line number of the title double-
+ clicked, and send it to the main gui class for opening in the
+ document editor.
+ """
+ theData = tItem.data(self.C_TITLE, Qt.UserRole)
+ tHandle = theData[0]
+ tLine = checkInt(theData[1], 1)
+
+ logger.verbose("User selected entry with handle %s on line %s" % (tHandle, tLine))
+ self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True)
+
+ return
+
+ def _itemSelected(self):
+ """Extract the handle and line number of the currently selected
+ title, and send it to the tree meta panel.
+ """
+ selItems = self.selectedItems()
+ if selItems:
+ tHandle = selItems[0].data(self.C_TITLE, Qt.UserRole)[0]
+ self.theParent.treeMeta.updateViewBox(tHandle)
+
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _populateTree(self):
+ """Build the tree based on the project index.
+ """
+ self.clearTree()
+
+ currTitle = None
+ currChapter = None
+ currScene = None
+
+ for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True):
+
+ tItem = self._createTreeItem(tHandle, sTitle, tKey, novIdx)
+ self._treeMap[tKey] = tItem
+
+ tLevel = novIdx["level"]
+ if tLevel == "H1":
+ self.addTopLevelItem(tItem)
+ currTitle = tItem
+ currChapter = None
+ currScene = None
+
+ elif tLevel == "H2":
+ if currTitle is None:
+ self.addTopLevelItem(tItem)
+ else:
+ currTitle.addChild(tItem)
+ currChapter = tItem
+ currScene = None
+
+ elif tLevel == "H3":
+ if currChapter is None:
+ if currTitle is None:
+ self.addTopLevelItem(tItem)
+ else:
+ currTitle.addChild(tItem)
+ else:
+ currChapter.addChild(tItem)
+ currScene = tItem
+
+ elif tLevel == "H4":
+ if currScene is None:
+ if currChapter is None:
+ if currTitle is None:
+ self.addTopLevelItem(tItem)
+ else:
+ currTitle.addChild(tItem)
+ else:
+ currChapter.addChild(tItem)
+ else:
+ currScene.addChild(tItem)
+
+ tItem.setExpanded(True)
+
+ self._lastBuild = time()
+
+ return
+
+ def _createTreeItem(self, tHandle, sTitle, titleKey, novIdx):
+ """Populate a tree item with all the column values.
+ """
+ newItem = QTreeWidgetItem()
+ hIcon = "doc_%s" % novIdx["level"].lower()
+ theData = (tHandle, sTitle[1:].lstrip("0"), titleKey)
+
+ wC = int(novIdx["wCount"])
+
+ newItem.setText(self.C_TITLE, novIdx["title"])
+ newItem.setData(self.C_TITLE, Qt.UserRole, theData)
+ newItem.setIcon(self.C_TITLE, self.theTheme.getIcon(hIcon))
+ newItem.setText(self.C_WORDS, f"{wC:n}")
+ newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
+
+ theRefs = self.theIndex.getReferences(tHandle, sTitle)
+ newItem.setText(self.C_POV, ", ".join(theRefs[nwKeyWords.POV_KEY]))
+
+ return newItem
+
+# END Class GuiNovelTree
diff --git a/nw/gui/outline.py b/nw/gui/outline.py
index 38046576..bee50162 100644
--- a/nw/gui/outline.py
+++ b/nw/gui/outline.py
@@ -165,7 +165,7 @@ class GuiOutline(QTreeWidget):
return
- def refreshTree(self, overRide=False):
+ def refreshTree(self, overRide=False, novelChanged=False):
"""Called whenever the Outline tab is activated and controls
what data to load, and if necessary, force a rebuild of the
tree.
@@ -177,13 +177,10 @@ class GuiOutline(QTreeWidget):
self.firstView = False
return
- # If the novel index has changed since the tree was last built,
- # we rebuild the tree from the updated index.
- lastChange = self.theParent.theIndex.timeNovel
- logger.verbose("Last outline build: %.3f" % self.lastBuild)
- logger.verbose("Novel index change: %.3f" % lastChange)
-
- doBuild = lastChange > self.lastBuild and self.theProject.autoOutline
+ # If the novel index or novel tree has changed since the tree
+ # was last built, we rebuild the tree from the updated index.
+ indexChanged = self.theIndex.novelChangedSince(self.lastBuild)
+ doBuild = (novelChanged or indexChanged) and self.theProject.autoOutline
if doBuild or overRide:
logger.debug("Rebuilding Project Outline")
self._populateTree()
@@ -227,6 +224,7 @@ class GuiOutline(QTreeWidget):
tHandle = selItems[0].data(self.colIndex[nwOutline.TITLE], Qt.UserRole)
sTitle = selItems[0].data(self.colIndex[nwOutline.LINE], Qt.UserRole)
self.theParent.projMeta.showItem(tHandle, sTitle)
+ self.theParent.treeView.setSelectedHandle(tHandle)
return
@@ -377,23 +375,12 @@ class GuiOutline(QTreeWidget):
currChapter = None
currScene = None
- for titleKey in self.theIndex.getNovelStructure(skipExcluded=True):
+ for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True):
- if len(titleKey) < 16:
- continue
-
- tHandle = titleKey[:13]
- sTitle = titleKey[14:]
-
- if tHandle not in self.theIndex.novelIndex:
- continue
- if sTitle not in self.theIndex.novelIndex[tHandle]:
- continue
-
- tLevel = self.theIndex.novelIndex[tHandle][sTitle]["level"]
- tItem = self._createTreeItem(tHandle, sTitle, tLevel)
- self.treeMap[titleKey] = tItem
+ tItem = self._createTreeItem(tHandle, sTitle, novIdx)
+ self.treeMap[tKey] = tItem
+ tLevel = novIdx["level"]
if tLevel == "H1":
self.addTopLevelItem(tItem)
currTitle = tItem
@@ -436,14 +423,12 @@ class GuiOutline(QTreeWidget):
return
- def _createTreeItem(self, tHandle, sTitle, tLevel):
+ def _createTreeItem(self, tHandle, sTitle, novIdx):
"""Populate a tree item with all the column values.
"""
- nwItem = self.theProject.projTree[tHandle]
- novIdx = self.theIndex.novelIndex[tHandle][sTitle]
-
+ nwItem = self.theProject.projTree[tHandle]
newItem = QTreeWidgetItem()
- hIcon = "doc_%s" % tLevel.lower()
+ hIcon = "doc_%s" % novIdx["level"].lower()
cC = int(novIdx["cCount"])
wC = int(novIdx["wCount"])
diff --git a/nw/gui/outlinedetails.py b/nw/gui/outlinedetails.py
index 0a6dce7f..507943dd 100644
--- a/nw/gui/outlinedetails.py
+++ b/nw/gui/outlinedetails.py
@@ -271,11 +271,10 @@ class GuiOutlineDetails(QScrollArea):
"""Update the content of the tree with the given handle and line
number pointing to a header.
"""
- try:
- nwItem = self.theProject.projTree[tHandle]
- novIdx = self.theIndex.novelIndex[tHandle][sTitle]
- theRefs = self.theIndex.getReferences(tHandle, sTitle)
- except Exception:
+ nwItem = self.theProject.projTree[tHandle]
+ novIdx = self.theIndex.getNovelData(tHandle, sTitle)
+ theRefs = self.theIndex.getReferences(tHandle, sTitle)
+ if nwItem is None or novIdx is None:
return False
if novIdx["level"] in self.LVL_MAP:
diff --git a/nw/gui/projsettings.py b/nw/gui/projsettings.py
index 6521e76c..52df0589 100644
--- a/nw/gui/projsettings.py
+++ b/nw/gui/projsettings.py
@@ -118,7 +118,7 @@ class GuiProjectSettings(PagedDialog):
self.theProject.setImportColours(importCol)
if self.tabStatus.colChanged or self.tabImport.colChanged:
- self.theParent.rebuildTree()
+ self.theParent.rebuildTrees()
if self.tabReplace.arChanged:
newList = self.tabReplace.getNewList()
diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py
index 7d7e7e54..85a1a0e6 100644
--- a/nw/gui/projtree.py
+++ b/nw/gui/projtree.py
@@ -29,7 +29,9 @@
import nw
import logging
-from PyQt5.QtCore import Qt, QSize
+from time import time
+
+from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
qApp, QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction
@@ -49,6 +51,9 @@ class GuiProjectTree(QTreeWidget):
C_EXPORT = 2
C_FLAGS = 3
+ novelItemChanged = pyqtSignal()
+ noteItemChanged = pyqtSignal()
+
def __init__(self, theParent):
QTreeWidget.__init__(self, theParent)
@@ -60,22 +65,27 @@ class GuiProjectTree(QTreeWidget):
self.theProject = theParent.theProject
self.theIndex = theParent.theIndex
- # Tree Settings
- self.theMap = {}
- self.treeChanged = False
+ # Internal Variables
+ self._treeMap = {}
+ self._treeChanged = False
+ self._timeChanged = 0
+ ##
+ # Build GUI
+ ##
+
+ # Context Menu
self.ctxMenu = GuiProjectTreeMenu(self)
- self.clearTree()
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self._rightClickMenu)
- # Build GUI
+ # Tree Settings
iPx = self.theTheme.baseIconSize
self.setIconSize(QSize(iPx, iPx))
self.setExpandsOnDoubleClick(True)
self.setIndentation(iPx)
self.setColumnCount(4)
self.setHeaderLabels(["Label", "Words", "Inc", "Flags"])
- self.setContextMenuPolicy(Qt.CustomContextMenu)
- self.customContextMenuRequested.connect(self._rightClickMenu)
treeHeadItem = self.headerItem()
treeHeadItem.setTextAlignment(self.C_COUNT, Qt.AlignRight)
@@ -102,7 +112,7 @@ class GuiProjectTree(QTreeWidget):
# Set Multiple Selection by CTRL
# Disabled for now, until the merge files option has been added
# self.setSelectionMode(QAbstractItemView.ExtendedSelection)
- # self.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
# Get user's column width preferences for NAME and COUNT
treeColWidth = self.mainConf.getTreeColWidths()
@@ -116,10 +126,11 @@ class GuiProjectTree(QTreeWidget):
# Set custom settings
self.initTree()
- logger.debug("GuiProjectTree initialisation complete")
+ # Internal Function Mapping
+ self.makeAlert = self.theParent.makeAlert
+ self.askQuestion = self.theParent.askQuestion
- # Internal Mapping
- self.makeAlert = self.theParent.makeAlert
+ logger.debug("GuiProjectTree initialisation complete")
return
@@ -147,8 +158,9 @@ class GuiProjectTree(QTreeWidget):
"""Clear the GUI content and the related map.
"""
self.clear()
- self.theMap = {}
- self.treeChanged = False
+ self._treeMap = {}
+ self._treeChanged = False
+ self._timeChanged = 0
return
def newTreeItem(self, itemType, itemClass):
@@ -274,10 +286,13 @@ class GuiProjectTree(QTreeWidget):
return False
pHandle = nwItem.itemParent
- if pHandle is not None and pHandle in self.theMap:
- self.theMap[pHandle].setExpanded(True)
+ if pHandle is not None and pHandle in self._treeMap:
+ self._treeMap[pHandle].setExpanded(True)
+
+ self._emitItemChange(tHandle)
self.clearSelection()
trItem.setSelected(True)
+
return True
def moveTreeItem(self, nStep):
@@ -318,6 +333,7 @@ class GuiProjectTree(QTreeWidget):
self.clearSelection()
cItem.setSelected(True)
self._setTreeChanged(True)
+ self._emitItemChange(tHandle)
return True
@@ -338,7 +354,7 @@ class GuiProjectTree(QTreeWidget):
"""Calls saveTreeOrder if there are unsaved changes, otherwise
does nothing.
"""
- if self.treeChanged:
+ if self._treeChanged:
logger.verbose("Flushing project tree to project class")
self.saveTreeOrder()
self._setTreeChanged(False)
@@ -391,7 +407,7 @@ class GuiProjectTree(QTreeWidget):
self.makeAlert("The Trash folder is already empty.", nwAlert.INFO)
return False
- msgYes = self.theParent.askQuestion(
+ msgYes = self.askQuestion(
"Empty Trash", "Permanently delete %d file(s) from Trash?" % nTrash
)
if not msgYes:
@@ -446,7 +462,7 @@ class GuiProjectTree(QTreeWidget):
# user if they want to permanently delete the file.
doPermanent = False
if not alreadyAsked:
- msgYes = self.theParent.askQuestion(
+ msgYes = self.askQuestion(
"Delete File", "Permanently delete file '%s'?" % nwItemS.itemName
)
if msgYes:
@@ -474,7 +490,7 @@ class GuiProjectTree(QTreeWidget):
# move it there.
doTrash = False
if askForTrash:
- msgYes = self.theParent.askQuestion(
+ msgYes = self.askQuestion(
"Delete File", "Move file '%s' to Trash?" % nwItemS.itemName
)
if msgYes:
@@ -533,7 +549,9 @@ class GuiProjectTree(QTreeWidget):
return True
def setTreeItemValues(self, tHandle):
- """Set the name and flag values for a tree item.
+ """Set the name and flag values for a tree item from a handle in
+ the project tree. Does not trigger a tree change as the data is
+ already coming from the project tree.
"""
trItem = self._getTreeItem(tHandle)
nwItem = self.theProject.projTree[tHandle]
@@ -622,9 +640,9 @@ class GuiProjectTree(QTreeWidget):
sent first.
"""
logger.debug("Building the project tree ...")
- self.clear()
- iCount = 0
+ self.clearTree()
+ iCount = 0
for nwItem in self.theProject.getProjectItems():
iCount += 1
self._addTreeItem(nwItem)
@@ -642,21 +660,10 @@ class GuiProjectTree(QTreeWidget):
return None
- def getSelectedHandles(self):
- """Return a list of all currently selected item handles.
- """
- selItems = self.selectedItems()
- selHandles = []
- for n in range(len(selItems)):
- if isinstance(selItems[n], QTreeWidgetItem):
- selHandles.append(selItems[n].data(self.C_NAME, Qt.UserRole))
-
- return selHandles
-
def setSelectedHandle(self, tHandle, doScroll=False):
"""Set a specific handle as the selected item.
"""
- if tHandle not in self.theMap:
+ if tHandle not in self._treeMap:
return False
tItem = self._getTreeItem(tHandle)
@@ -664,7 +671,7 @@ class GuiProjectTree(QTreeWidget):
return False
self.clearSelection()
- self.theMap[tHandle].setSelected(True)
+ self._treeMap[tHandle].setSelected(True)
selItems = self.selectedIndexes()
if selItems and doScroll:
@@ -672,6 +679,11 @@ class GuiProjectTree(QTreeWidget):
return True
+ def changedSince(self, checkTime):
+ """Check if the tree has changed since a given time.
+ """
+ return self._timeChanged > checkTime
+
##
# Slots
##
@@ -699,7 +711,7 @@ class GuiProjectTree(QTreeWidget):
def mousePressEvent(self, theEvent):
"""Overload mousePressEvent to clear selection if clicking the
mouse in a blank area of the tree view, and to load a document
- for viewing if the suer middle clicked.
+ for viewing if the user middle-clicked.
"""
QTreeWidget.mousePressEvent(self, theEvent)
@@ -783,6 +795,10 @@ class GuiProjectTree(QTreeWidget):
else:
self.theIndex.reIndexHandle(sHandle)
+ # Trigger dependent updates
+ self._setTreeChanged(True)
+ self._emitItemChange(sHandle)
+
else:
theEvent.ignore()
logger.debug("Drag'n'drop of item %s not accepted" % sHandle)
@@ -797,7 +813,7 @@ class GuiProjectTree(QTreeWidget):
def _getTreeItem(self, tHandle):
"""Returns the QTreeWidgetItem of a given item handle.
"""
- return self.theMap.get(tHandle, None)
+ return self._treeMap.get(tHandle, None)
def _scanChildren(self, theList, theItem, theIndex):
"""This is a recursive function returning all items in a tree
@@ -834,7 +850,7 @@ class GuiProjectTree(QTreeWidget):
newItem.setData(self.C_NAME, Qt.UserRole, tHandle)
newItem.setData(self.C_COUNT, Qt.UserRole, 0)
- self.theMap[tHandle] = newItem
+ self._treeMap[tHandle] = newItem
if pHandle is None:
if nwItem.itemType == nwItemType.ROOT:
self.addTopLevelItem(newItem)
@@ -845,20 +861,20 @@ class GuiProjectTree(QTreeWidget):
self.makeAlert(
"There is nowhere to add item with name '%s'" % nwItem.itemName, nwAlert.ERROR
)
- del self.theMap[tHandle]
+ del self._treeMap[tHandle]
return None
else:
byIndex = -1
- if nHandle is not None and nHandle in self.theMap:
+ if nHandle is not None and nHandle in self._treeMap:
try:
- byIndex = self.theMap[pHandle].indexOfChild(self.theMap[nHandle])
+ byIndex = self._treeMap[pHandle].indexOfChild(self._treeMap[nHandle])
except Exception:
logger.error("Failed to get index of item with handle %s" % nHandle)
if byIndex >= 0:
- self.theMap[pHandle].insertChild(byIndex+1, newItem)
+ self._treeMap[pHandle].insertChild(byIndex+1, newItem)
else:
- self.theMap[pHandle].addChild(newItem)
+ self._treeMap[pHandle].addChild(newItem)
self.propagateCount(tHandle, nwItem.wordCount)
self.setTreeItemValues(tHandle)
@@ -910,7 +926,6 @@ class GuiProjectTree(QTreeWidget):
pHandle = trItemP.data(self.C_NAME, Qt.UserRole)
nwItemS.setParent(pHandle)
self.setTreeItemValues(tHandle)
- self._setTreeChanged(True)
logger.debug("The parent of item %s has been changed to %s" % (tHandle, pHandle))
@@ -919,11 +934,27 @@ class GuiProjectTree(QTreeWidget):
def _setTreeChanged(self, theState):
"""Set the tree change flag, and propagate to the project.
"""
- self.treeChanged = theState
+ self._treeChanged = theState
if theState:
+ self._timeChanged = time()
self.theProject.setProjectChanged(True)
return
+ def _emitItemChange(self, tHandle):
+ """Emit an item change signal for a given handle.
+ """
+ nwItem = self.theProject.projTree[tHandle]
+ if nwItem is None:
+ return
+
+ if nwItem.itemType == nwItemType.FILE:
+ if nwItem.itemClass == nwItemClass.NOVEL:
+ self.novelItemChanged.emit()
+ else:
+ self.noteItemChanged.emit()
+
+ return
+
# END Class GuiProjectTree
class GuiProjectTreeMenu(QMenu):
diff --git a/nw/guimain.py b/nw/guimain.py
index 920a689e..8640aa52 100644
--- a/nw/guimain.py
+++ b/nw/guimain.py
@@ -32,7 +32,7 @@ import os
from datetime import datetime
from time import time
-from PyQt5.QtCore import Qt, QTimer, QThreadPool
+from PyQt5.QtCore import Qt, QTimer, QThreadPool, pyqtSlot
from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence, QCursor
from PyQt5.QtWidgets import (
qApp, QMainWindow, QVBoxLayout, QWidget, QSplitter, QFileDialog, QShortcut,
@@ -42,9 +42,9 @@ from PyQt5.QtWidgets import (
from nw.gui import (
GuiAbout, GuiBuildNovel, GuiDocEditor, GuiDocMerge, GuiDocSplit,
GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor,
- GuiMainMenu, GuiMainStatus, GuiOutline, GuiOutlineDetails, GuiPreferences,
- GuiProjectLoad, GuiProjectSettings, GuiProjectTree, GuiProjectWizard,
- GuiTheme, GuiWritingStats
+ GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails,
+ GuiPreferences, GuiProjectLoad, GuiProjectSettings, GuiProjectTree,
+ GuiProjectWizard, GuiTheme, GuiWritingStats
)
from nw.core import NWProject, NWDoc, NWIndex
from nw.constants import nwItemType, nwItemClass, nwAlert, nwConst
@@ -99,6 +99,7 @@ class GuiMain(QMainWindow):
# Main GUI Elements
self.statusBar = GuiMainStatus(self)
self.treeView = GuiProjectTree(self)
+ self.novelView = GuiNovelTree(self)
self.docEditor = GuiDocEditor(self)
self.viewMeta = GuiDocViewDetails(self)
self.docViewer = GuiDocViewer(self)
@@ -111,11 +112,24 @@ class GuiMain(QMainWindow):
self.statusIcons = []
self.importIcons = []
+ # Project Tabs : Project / Novel
+ self.projTabs = QTabWidget()
+ self.projTabs.setTabPosition(QTabWidget.South)
+ self.projTabs.setStyleSheet("QTabWidget::pane {border: 0;};")
+ self.projTabs.addTab(self.treeView, "Project")
+ self.projTabs.addTab(self.novelView, "Novel")
+ self.projTabs.currentChanged.connect(self._projTabsChanged)
+
+ tabFont = self.projTabs.tabBar().font()
+ tabFont.setPointSize(round(0.9*self.theTheme.fontPointSize))
+ self.projTabs.tabBar().setFont(tabFont)
+
# Project Tree View
self.treePane = QWidget()
self.treeBox = QVBoxLayout()
self.treeBox.setContentsMargins(0, 0, 0, 0)
- self.treeBox.addWidget(self.treeView)
+ self.treeBox.setSpacing(0)
+ self.treeBox.addWidget(self.projTabs)
self.treeBox.addWidget(self.treeMeta)
self.treePane.setLayout(self.treeBox)
@@ -136,31 +150,33 @@ class GuiMain(QMainWindow):
self.splitOutline.addWidget(self.projMeta)
self.splitOutline.setSizes(self.mainConf.getOutlinePanePos())
- # Main Tabs : Edirot / Outline
- self.tabWidget = QTabWidget()
- self.tabWidget.setTabPosition(QTabWidget.East)
- self.tabWidget.setStyleSheet("QTabWidget::pane {border: 0;}")
- self.tabWidget.addTab(self.splitDocs, "Editor")
- self.tabWidget.addTab(self.splitOutline, "Outline")
- self.tabWidget.currentChanged.connect(self._mainTabChanged)
+ # Main Tabs : Editor / Outline
+ self.mainTabs = QTabWidget()
+ self.mainTabs.setTabPosition(QTabWidget.East)
+ self.mainTabs.setStyleSheet("QTabWidget::pane {border: 0;}")
+ self.mainTabs.addTab(self.splitDocs, "Editor")
+ self.mainTabs.addTab(self.splitOutline, "Outline")
+ self.mainTabs.currentChanged.connect(self._mainTabChanged)
# Splitter : Project Tree / Main Tabs
xCM = self.mainConf.pxInt(4)
self.splitMain = QSplitter(Qt.Horizontal)
self.splitMain.setContentsMargins(xCM, xCM, xCM, xCM)
self.splitMain.addWidget(self.treePane)
- self.splitMain.addWidget(self.tabWidget)
+ self.splitMain.addWidget(self.mainTabs)
self.splitMain.setSizes(self.mainConf.getMainPanePos())
# Indices of All Splitter Widgets
- self.idxTree = self.splitMain.indexOf(self.treePane)
- self.idxMain = self.splitMain.indexOf(self.tabWidget)
- self.idxEditor = self.splitDocs.indexOf(self.docEditor)
- self.idxViewer = self.splitDocs.indexOf(self.splitView)
- self.idxViewDoc = self.splitView.indexOf(self.docViewer)
- self.idxViewMeta = self.splitView.indexOf(self.viewMeta)
- self.idxTabEdit = self.tabWidget.indexOf(self.splitDocs)
- self.idxTabProj = self.tabWidget.indexOf(self.splitOutline)
+ self.idxTree = self.splitMain.indexOf(self.treePane)
+ self.idxMain = self.splitMain.indexOf(self.mainTabs)
+ self.idxEditor = self.splitDocs.indexOf(self.docEditor)
+ self.idxViewer = self.splitDocs.indexOf(self.splitView)
+ self.idxViewDoc = self.splitView.indexOf(self.docViewer)
+ self.idxViewMeta = self.splitView.indexOf(self.viewMeta)
+ self.idxTabEdit = self.mainTabs.indexOf(self.splitDocs)
+ self.idxTabProj = self.mainTabs.indexOf(self.splitOutline)
+ self.idxTreeView = self.projTabs.indexOf(self.treeView)
+ self.idxNovelView = self.projTabs.indexOf(self.novelView)
# Splitter Behaviour
self.splitMain.setCollapsible(self.idxTree, False)
@@ -177,7 +193,8 @@ class GuiMain(QMainWindow):
# Initialise the Project Tree
self.treeView.itemSelectionChanged.connect(self._treeSingleClick)
self.treeView.itemDoubleClicked.connect(self._treeDoubleClick)
- self.rebuildTree()
+ self.treeView.novelItemChanged.connect(self._treeNovelItemChanged)
+ self.rebuildTrees()
# Set Main Window Elements
self.setMenuBar(self.mainMenu)
@@ -253,6 +270,7 @@ class GuiMain(QMainWindow):
"""Wrapper function to clear all sub-elements of the main GUI.
"""
self.treeView.clearTree()
+ self.novelView.clearTree()
self.docEditor.clearEditor()
self.closeDocViewer()
self.statusBar.clearStatus()
@@ -299,7 +317,7 @@ class GuiMain(QMainWindow):
logger.info("Creating new project")
if self.theProject.newProject(projData):
- self.rebuildTree()
+ self.rebuildTrees()
self.saveProject()
self.hasProject = True
self.statusBar.setRefTime(self.theProject.projOpened)
@@ -355,7 +373,7 @@ class GuiMain(QMainWindow):
self.theIndex.clearIndex()
self.clearGUI()
self.hasProject = False
- self.tabWidget.setCurrentWidget(self.splitDocs)
+ self.mainTabs.setCurrentWidget(self.splitDocs)
return saveOK
@@ -371,7 +389,7 @@ class GuiMain(QMainWindow):
return False
# Switch main tab to editor view
- self.tabWidget.setCurrentWidget(self.splitDocs)
+ self.mainTabs.setCurrentWidget(self.splitDocs)
# Try to open the project
if not self.theProject.openProject(projFile):
@@ -423,7 +441,7 @@ class GuiMain(QMainWindow):
# Update GUI
self._setWindowTitle(self.theProject.projName)
- self.rebuildTree()
+ self.rebuildTrees()
self.docEditor.setDictionaries()
self.docEditor.setSpellCheck(self.theProject.spellCheck)
self.mainMenu.setAutoOutline(self.theProject.autoOutline)
@@ -490,7 +508,7 @@ class GuiMain(QMainWindow):
return False
self.closeDocument()
- self.tabWidget.setCurrentWidget(self.splitDocs)
+ self.mainTabs.setCurrentWidget(self.splitDocs)
if self.docEditor.loadText(tHandle, tLine):
if changeFocus:
self.docEditor.setFocus()
@@ -575,7 +593,7 @@ class GuiMain(QMainWindow):
return False
# Make sure main tab is in Editor view
- self.tabWidget.setCurrentWidget(self.splitDocs)
+ self.mainTabs.setCurrentWidget(self.splitDocs)
logger.debug("Viewing document with handle %s" % tHandle)
if self.docViewer.loadText(tHandle):
@@ -739,13 +757,13 @@ class GuiMain(QMainWindow):
return
- def rebuildTree(self):
+ def rebuildTrees(self):
"""Rebuild the project tree.
"""
self._makeStatusIcons()
self._makeImportIcons()
- self.treeView.clearTree()
self.treeView.buildTree()
+ self.novelView.refreshTree()
return
def rebuildIndex(self, beQuiet=False):
@@ -803,7 +821,7 @@ class GuiMain(QMainWindow):
return False
logger.verbose("Forcing a rebuild of the Project Outline")
- self.tabWidget.setCurrentWidget(self.splitOutline)
+ self.mainTabs.setCurrentWidget(self.splitOutline)
self.projView.refreshTree(overRide=True)
return True
@@ -866,6 +884,7 @@ class GuiMain(QMainWindow):
self.docEditor.initEditor()
self.docViewer.initViewer()
self.treeView.initTree()
+ self.novelView.initTree()
self.projView.initOutline()
self.projMeta.initDetails()
@@ -1027,6 +1046,7 @@ class GuiMain(QMainWindow):
self.mainConf.setShowRefPanel(self.viewMeta.isVisible())
self.mainConf.setTreeColWidths(self.treeView.getColumnSizes())
+ self.mainConf.setNovelColWidths(self.novelView.getColumnSizes())
if not self.mainConf.isFullScreen:
self.mainConf.setWinSize(self.width(), self.height())
@@ -1083,7 +1103,7 @@ class GuiMain(QMainWindow):
self.mainMenu.aFocusMode.setChecked(self.isFocusMode)
if self.isFocusMode:
logger.debug("Activating Focus Mode")
- self.tabWidget.setCurrentWidget(self.splitDocs)
+ self.mainTabs.setCurrentWidget(self.splitDocs)
else:
logger.debug("Deactivating Focus Mode")
@@ -1091,7 +1111,7 @@ class GuiMain(QMainWindow):
self.treePane.setVisible(isVisible)
self.statusBar.setVisible(isVisible)
self.mainMenu.setVisible(isVisible)
- self.tabWidget.tabBar().setVisible(isVisible)
+ self.mainTabs.tabBar().setVisible(isVisible)
hideDocFooter = self.isFocusMode and self.mainConf.hideFocusFooter
self.docEditor.docFooter.setVisible(not hideDocFooter)
@@ -1300,9 +1320,10 @@ class GuiMain(QMainWindow):
return
##
- # Signal Handlers
+ # Slots
##
+ @pyqtSlot()
def _treeSingleClick(self):
"""Single click on a project tree item just updates the details
panel below the tree.
@@ -1312,12 +1333,14 @@ class GuiMain(QMainWindow):
self.treeMeta.updateViewBox(sHandle)
return
+ @pyqtSlot("QTreeWidgetItem*", int)
def _treeDoubleClick(self, tItem, colNo):
"""The user double-clicked an item in the tree. If it is a file,
we open it. Otherwise, we do nothing.
"""
tHandle = tItem.data(self.treeView.C_NAME, Qt.UserRole)
logger.verbose("User double clicked tree item with handle %s" % tHandle)
+
nwItem = self.theProject.projTree[tHandle]
if nwItem is not None:
if nwItem.itemType == nwItemType.FILE:
@@ -1328,6 +1351,20 @@ class GuiMain(QMainWindow):
return
+ @pyqtSlot()
+ def _treeNovelItemChanged(self):
+ """Triggered when there is a change to a novel item in the
+ project tree.
+ """
+ if self.mainTabs.currentIndex() == self.idxTabProj:
+ logger.verbose("Novel tree changed while Outline tab active")
+ if self.hasProject:
+ self.treeView.flushTreeOrder()
+ self.projView.refreshTree(novelChanged=True)
+
+ return
+
+ @pyqtSlot()
def _treeKeyPressReturn(self):
"""The user pressed return on an item in the tree. If it is a
file, we open it. Otherwise, we do nothing. Pressing return does
@@ -1335,6 +1372,7 @@ class GuiMain(QMainWindow):
"""
tHandle = self.treeView.getSelectedHandle()
logger.verbose("User pressed return on tree item with handle %s" % tHandle)
+
nwItem = self.theProject.projTree[tHandle]
if nwItem is not None:
if nwItem.itemType == nwItemType.FILE:
@@ -1342,8 +1380,10 @@ class GuiMain(QMainWindow):
self.openDocument(tHandle, changeFocus=False, doScroll=False)
else:
logger.verbose("Requested item %s is a folder" % tHandle)
+
return
+ @pyqtSlot()
def _keyPressEscape(self):
"""When the escape key is pressed somewhere in the main window,
do the following, in order:
@@ -1352,8 +1392,10 @@ class GuiMain(QMainWindow):
self.docEditor.closeSearch()
elif self.isFocusMode:
self.toggleFocusMode()
+
return
+ @pyqtSlot(int)
def _mainTabChanged(self, tabIndex):
"""Activated when the main window tab is changed.
"""
@@ -1363,6 +1405,27 @@ class GuiMain(QMainWindow):
logger.verbose("Project outline tab activated")
if self.hasProject:
self.projView.refreshTree()
+
+ return
+
+ @pyqtSlot(int)
+ def _projTabsChanged(self, tabIndex):
+ """Activated when the project view tab is changed.
+ """
+ sHandle = None
+
+ if tabIndex == self.idxTreeView:
+ logger.verbose("Project tree tab activated")
+ sHandle = self.treeView.getSelectedHandle()
+
+ elif tabIndex == self.idxNovelView:
+ logger.verbose("Novel tree tab activated")
+ if self.hasProject:
+ self.novelView.refreshTree()
+ sHandle = self.novelView.getSelectedHandle()
+
+ self.treeMeta.updateViewBox(sHandle)
+
return
# END Class GuiMain
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index db32b89d..33eeabdf 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,13 +1,13 @@
-
+
Sample Project
Sample Project
Jane Smith
Jay Doh
- 823
+ 824
153
- 39446
+ 39448
False
diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf
index 5ff6f852..27f2062d 100644
--- a/tests/reference/baseConfig_novelwriter.conf
+++ b/tests/reference/baseConfig_novelwriter.conf
@@ -11,6 +11,7 @@ lastnotes = 1.0
[Sizes]
geometry = 1200, 650
treecols = 200, 50, 30
+novelcols = 200, 50
projcols = 200, 60, 140
mainpane = 300, 800
docpane = 400, 400
diff --git a/tests/reference/guiPreferences_novelwriter.conf b/tests/reference/guiPreferences_novelwriter.conf
index cdbc5337..5b10156f 100644
--- a/tests/reference/guiPreferences_novelwriter.conf
+++ b/tests/reference/guiPreferences_novelwriter.conf
@@ -11,6 +11,7 @@ lastnotes = 1.0
[Sizes]
geometry = 1100, 650
treecols = 120, 30, 50
+novelcols = 200, 50
projcols = 140, 55, 140
mainpane = 300, 800
docpane = 400, 400
diff --git a/tests/test_base_config.py b/tests/test_base_config.py
index fe755646..bc9dd161 100644
--- a/tests/test_base_config.py
+++ b/tests/test_base_config.py
@@ -304,6 +304,19 @@ def testBaseConfig_SettersGetters(tmpConf, tmpDir, outDir, refDir):
assert tmpConf.setTreeColWidths([200, 50, 30])
+ # Novel Tree Columns
+ tmpConf.guiScale = 2.0
+ assert tmpConf.setNovelColWidths([10, 20])
+ assert tmpConf.getNovelColWidths() == [10, 20]
+ assert tmpConf.novelColWidth == [5, 10]
+
+ tmpConf.guiScale = 1.0
+ assert tmpConf.setNovelColWidths([10, 20])
+ assert tmpConf.getNovelColWidths() == [10, 20]
+ assert tmpConf.novelColWidth == [10, 20]
+
+ assert tmpConf.setNovelColWidths([200, 50])
+
# Project Settings Tree Columns
tmpConf.guiScale = 2.0
assert tmpConf.setProjColWidths([10, 20, 30])
diff --git a/tests/test_core_index.py b/tests/test_core_index.py
index ad87c7f3..b19af00d 100644
--- a/tests/test_core_index.py
+++ b/tests/test_core_index.py
@@ -56,30 +56,30 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, outDir, refDir):
assert theIndex.saveIndex()
# Take a copy of the index
- tagIndex = str(theIndex.tagIndex)
- refIndex = str(theIndex.refIndex)
- novelIndex = str(theIndex.novelIndex)
- noteIndex = str(theIndex.noteIndex)
- textCounts = str(theIndex.textCounts)
+ tagIndex = str(theIndex._tagIndex)
+ refIndex = str(theIndex._refIndex)
+ novelIndex = str(theIndex._novelIndex)
+ noteIndex = str(theIndex._noteIndex)
+ textCounts = str(theIndex._textCounts)
# Delete a handle
- assert theIndex.tagIndex.get("Bod", None) is not None
- assert theIndex.refIndex.get("4c4f28287af27", None) is not None
- assert theIndex.noteIndex.get("4c4f28287af27", None) is not None
- assert theIndex.textCounts.get("4c4f28287af27", None) is not None
+ assert theIndex._tagIndex.get("Bod", None) is not None
+ assert theIndex._refIndex.get("4c4f28287af27", None) is not None
+ assert theIndex._noteIndex.get("4c4f28287af27", None) is not None
+ assert theIndex._textCounts.get("4c4f28287af27", None) is not None
theIndex.deleteHandle("4c4f28287af27")
- assert theIndex.tagIndex.get("Bod", None) is None
- assert theIndex.refIndex.get("4c4f28287af27", None) is None
- assert theIndex.noteIndex.get("4c4f28287af27", None) is None
- assert theIndex.textCounts.get("4c4f28287af27", None) is None
+ assert theIndex._tagIndex.get("Bod", None) is None
+ assert theIndex._refIndex.get("4c4f28287af27", None) is None
+ assert theIndex._noteIndex.get("4c4f28287af27", None) is None
+ assert theIndex._textCounts.get("4c4f28287af27", None) is None
# Clear the index
theIndex.clearIndex()
- assert not theIndex.tagIndex
- assert not theIndex.refIndex
- assert not theIndex.novelIndex
- assert not theIndex.noteIndex
- assert not theIndex.textCounts
+ assert not theIndex._tagIndex
+ assert not theIndex._refIndex
+ assert not theIndex._novelIndex
+ assert not theIndex._noteIndex
+ assert not theIndex._textCounts
# Make the load fail
monkeypatch.setattr(json, "load", doPanic)
@@ -89,46 +89,46 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, outDir, refDir):
monkeypatch.undo()
assert theIndex.loadIndex()
- assert str(theIndex.tagIndex) == tagIndex
- assert str(theIndex.refIndex) == refIndex
- assert str(theIndex.novelIndex) == novelIndex
- assert str(theIndex.noteIndex) == noteIndex
- assert str(theIndex.textCounts) == textCounts
+ assert str(theIndex._tagIndex) == tagIndex
+ assert str(theIndex._refIndex) == refIndex
+ assert str(theIndex._novelIndex) == novelIndex
+ assert str(theIndex._noteIndex) == noteIndex
+ assert str(theIndex._textCounts) == textCounts
# Break the index and check that we notice
assert not theIndex.indexBroken
- theIndex.tagIndex["Bod"].append("Stuff") # No longer len() == 4
+ theIndex._tagIndex["Bod"].append("Stuff") # No longer len() == 4
theIndex.checkIndex()
assert theIndex.indexBroken
assert theIndex.loadIndex()
assert not theIndex.indexBroken
- theIndex.refIndex["fb609cd8319dc"]["T000001"]["tags"].append("Stuff") # No longer len() == 3
+ theIndex._refIndex["fb609cd8319dc"]["T000001"]["tags"].append("Stuff") # No longer len() == 3
theIndex.checkIndex()
assert theIndex.indexBroken
assert theIndex.loadIndex()
assert not theIndex.indexBroken
- theIndex.novelIndex["7a992350f3eb6"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8
+ theIndex._novelIndex["7a992350f3eb6"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8
theIndex.checkIndex()
assert theIndex.indexBroken
assert theIndex.loadIndex()
assert not theIndex.indexBroken
- theIndex.noteIndex["4c4f28287af27"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8
+ theIndex._noteIndex["4c4f28287af27"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8
theIndex.checkIndex()
assert theIndex.indexBroken
assert theIndex.loadIndex()
assert not theIndex.indexBroken
- theIndex.textCounts["7a992350f3eb6"].append("Stuff") # No longer len() == 3
+ theIndex._textCounts["7a992350f3eb6"].append("Stuff") # No longer len() == 3
theIndex.checkIndex()
assert theIndex.indexBroken
# Make the try/except trigger as well
assert theIndex.loadIndex()
assert not theIndex.indexBroken
- theIndex.refIndex["fb609cd8319dc"]["T000001"] = {"tagssss": []} # Wrong key name
+ theIndex._refIndex["fb609cd8319dc"]["T000001"] = {"tagssss": []} # Wrong key name
theIndex.checkIndex()
assert theIndex.indexBroken
@@ -205,6 +205,10 @@ def testCoreIndex_CheckThese(nwMinimal, dummyGUI):
nItem = theProject.projTree[nHandle]
cItem = theProject.projTree[cHandle]
+ assert not theIndex.novelChangedSince(0)
+ assert not theIndex.notesChangedSince(0)
+ assert not theIndex.indexChangedSince(0)
+
assert theIndex.scanText(cHandle, (
"# Jane Smith\n"
"@tag: Jane"
@@ -213,8 +217,12 @@ def testCoreIndex_CheckThese(nwMinimal, dummyGUI):
"# Hello World!\n"
"@pov: Jane"
))
- assert theIndex.tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]}
- assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Hello World!"
+ assert theIndex._tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]}
+ assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!"
+
+ assert theIndex.novelChangedSince(0)
+ assert theIndex.notesChangedSince(0)
+ assert theIndex.indexChangedSince(0)
assert theIndex.checkThese([], cItem) == []
assert theIndex.checkThese(["@tag", "Jane"], cItem) == [True, True]
@@ -285,8 +293,8 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI):
"This is a story about Jane Smith.\n\n"
"Well, not really.\n"
))
- assert str(theIndex.tagIndex) == "{'Jane': [2, '%s', 'CHARACTER', 'T000001']}" % cHandle
- assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Hello World!"
+ assert str(theIndex._tagIndex) == "{'Jane': [2, '%s', 'CHARACTER', 'T000001']}" % cHandle
+ assert theIndex.getNovelData(nHandle, "T000001")["title"] == "Hello World!"
# Check that title sections are indexed properly
assert theIndex.scanText(nHandle, (
@@ -305,68 +313,68 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI):
"##### Title Five\n\n" # Not interpreted as a title, the hashes is counted as a word
"Paragraph Five.\n\n"
))
- assert theIndex.refIndex[nHandle].get("T000000", None) is not None # Always there
- assert theIndex.refIndex[nHandle].get("T000001", None) is not None # Heading 1
- assert theIndex.refIndex[nHandle].get("T000002", None) is None
- assert theIndex.refIndex[nHandle].get("T000003", None) is None
- assert theIndex.refIndex[nHandle].get("T000004", None) is None
- assert theIndex.refIndex[nHandle].get("T000005", None) is None
- assert theIndex.refIndex[nHandle].get("T000006", None) is None
- assert theIndex.refIndex[nHandle].get("T000007", None) is not None # Heading 2
- assert theIndex.refIndex[nHandle].get("T000008", None) is None
- assert theIndex.refIndex[nHandle].get("T000009", None) is None
- assert theIndex.refIndex[nHandle].get("T000010", None) is None
- assert theIndex.refIndex[nHandle].get("T000011", None) is None
- assert theIndex.refIndex[nHandle].get("T000012", None) is None
- assert theIndex.refIndex[nHandle].get("T000013", None) is not None # Heading 3
- assert theIndex.refIndex[nHandle].get("T000014", None) is None
- assert theIndex.refIndex[nHandle].get("T000015", None) is None
- assert theIndex.refIndex[nHandle].get("T000016", None) is None
- assert theIndex.refIndex[nHandle].get("T000017", None) is None
- assert theIndex.refIndex[nHandle].get("T000018", None) is None
- assert theIndex.refIndex[nHandle].get("T000019", None) is not None # Heading 4
- assert theIndex.refIndex[nHandle].get("T000020", None) is None
- assert theIndex.refIndex[nHandle].get("T000021", None) is None
- assert theIndex.refIndex[nHandle].get("T000022", None) is None
- assert theIndex.refIndex[nHandle].get("T000023", None) is None
- assert theIndex.refIndex[nHandle].get("T000024", None) is None
- assert theIndex.refIndex[nHandle].get("T000025", None) is None
- assert theIndex.refIndex[nHandle].get("T000026", None) is None
+ assert theIndex._refIndex[nHandle].get("T000000", None) is not None # Always there
+ assert theIndex._refIndex[nHandle].get("T000001", None) is not None # Heading 1
+ assert theIndex._refIndex[nHandle].get("T000002", None) is None
+ assert theIndex._refIndex[nHandle].get("T000003", None) is None
+ assert theIndex._refIndex[nHandle].get("T000004", None) is None
+ assert theIndex._refIndex[nHandle].get("T000005", None) is None
+ assert theIndex._refIndex[nHandle].get("T000006", None) is None
+ assert theIndex._refIndex[nHandle].get("T000007", None) is not None # Heading 2
+ assert theIndex._refIndex[nHandle].get("T000008", None) is None
+ assert theIndex._refIndex[nHandle].get("T000009", None) is None
+ assert theIndex._refIndex[nHandle].get("T000010", None) is None
+ assert theIndex._refIndex[nHandle].get("T000011", None) is None
+ assert theIndex._refIndex[nHandle].get("T000012", None) is None
+ assert theIndex._refIndex[nHandle].get("T000013", None) is not None # Heading 3
+ assert theIndex._refIndex[nHandle].get("T000014", None) is None
+ assert theIndex._refIndex[nHandle].get("T000015", None) is None
+ assert theIndex._refIndex[nHandle].get("T000016", None) is None
+ assert theIndex._refIndex[nHandle].get("T000017", None) is None
+ assert theIndex._refIndex[nHandle].get("T000018", None) is None
+ assert theIndex._refIndex[nHandle].get("T000019", None) is not None # Heading 4
+ assert theIndex._refIndex[nHandle].get("T000020", None) is None
+ assert theIndex._refIndex[nHandle].get("T000021", None) is None
+ assert theIndex._refIndex[nHandle].get("T000022", None) is None
+ assert theIndex._refIndex[nHandle].get("T000023", None) is None
+ assert theIndex._refIndex[nHandle].get("T000024", None) is None
+ assert theIndex._refIndex[nHandle].get("T000025", None) is None
+ assert theIndex._refIndex[nHandle].get("T000026", None) is None
- assert theIndex.novelIndex[nHandle]["T000001"]["level"] == "H1"
- assert theIndex.novelIndex[nHandle]["T000007"]["level"] == "H2"
- assert theIndex.novelIndex[nHandle]["T000013"]["level"] == "H3"
- assert theIndex.novelIndex[nHandle]["T000019"]["level"] == "H4"
+ assert theIndex._novelIndex[nHandle]["T000001"]["level"] == "H1"
+ assert theIndex._novelIndex[nHandle]["T000007"]["level"] == "H2"
+ assert theIndex._novelIndex[nHandle]["T000013"]["level"] == "H3"
+ assert theIndex._novelIndex[nHandle]["T000019"]["level"] == "H4"
- assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Title One"
- assert theIndex.novelIndex[nHandle]["T000007"]["title"] == "Title Two"
- assert theIndex.novelIndex[nHandle]["T000013"]["title"] == "Title Three"
- assert theIndex.novelIndex[nHandle]["T000019"]["title"] == "Title Four"
+ assert theIndex._novelIndex[nHandle]["T000001"]["title"] == "Title One"
+ assert theIndex._novelIndex[nHandle]["T000007"]["title"] == "Title Two"
+ assert theIndex._novelIndex[nHandle]["T000013"]["title"] == "Title Three"
+ assert theIndex._novelIndex[nHandle]["T000019"]["title"] == "Title Four"
- assert theIndex.novelIndex[nHandle]["T000001"]["layout"] == "SCENE"
- assert theIndex.novelIndex[nHandle]["T000007"]["layout"] == "SCENE"
- assert theIndex.novelIndex[nHandle]["T000013"]["layout"] == "SCENE"
- assert theIndex.novelIndex[nHandle]["T000019"]["layout"] == "SCENE"
+ assert theIndex._novelIndex[nHandle]["T000001"]["layout"] == "SCENE"
+ assert theIndex._novelIndex[nHandle]["T000007"]["layout"] == "SCENE"
+ assert theIndex._novelIndex[nHandle]["T000013"]["layout"] == "SCENE"
+ assert theIndex._novelIndex[nHandle]["T000019"]["layout"] == "SCENE"
- assert theIndex.novelIndex[nHandle]["T000001"]["synopsis"] == "Synopsis One."
- assert theIndex.novelIndex[nHandle]["T000007"]["synopsis"] == "Synopsis Two."
- assert theIndex.novelIndex[nHandle]["T000013"]["synopsis"] == "Synopsis Three."
- assert theIndex.novelIndex[nHandle]["T000019"]["synopsis"] == "Synopsis Four."
+ assert theIndex._novelIndex[nHandle]["T000001"]["synopsis"] == "Synopsis One."
+ assert theIndex._novelIndex[nHandle]["T000007"]["synopsis"] == "Synopsis Two."
+ assert theIndex._novelIndex[nHandle]["T000013"]["synopsis"] == "Synopsis Three."
+ assert theIndex._novelIndex[nHandle]["T000019"]["synopsis"] == "Synopsis Four."
- assert theIndex.novelIndex[nHandle]["T000001"]["cCount"] == 23
- assert theIndex.novelIndex[nHandle]["T000007"]["cCount"] == 23
- assert theIndex.novelIndex[nHandle]["T000013"]["cCount"] == 27
- assert theIndex.novelIndex[nHandle]["T000019"]["cCount"] == 56
+ assert theIndex._novelIndex[nHandle]["T000001"]["cCount"] == 23
+ assert theIndex._novelIndex[nHandle]["T000007"]["cCount"] == 23
+ assert theIndex._novelIndex[nHandle]["T000013"]["cCount"] == 27
+ assert theIndex._novelIndex[nHandle]["T000019"]["cCount"] == 56
- assert theIndex.novelIndex[nHandle]["T000001"]["wCount"] == 4
- assert theIndex.novelIndex[nHandle]["T000007"]["wCount"] == 4
- assert theIndex.novelIndex[nHandle]["T000013"]["wCount"] == 4
- assert theIndex.novelIndex[nHandle]["T000019"]["wCount"] == 9
+ assert theIndex._novelIndex[nHandle]["T000001"]["wCount"] == 4
+ assert theIndex._novelIndex[nHandle]["T000007"]["wCount"] == 4
+ assert theIndex._novelIndex[nHandle]["T000013"]["wCount"] == 4
+ assert theIndex._novelIndex[nHandle]["T000019"]["wCount"] == 9
- assert theIndex.novelIndex[nHandle]["T000001"]["pCount"] == 1
- assert theIndex.novelIndex[nHandle]["T000007"]["pCount"] == 1
- assert theIndex.novelIndex[nHandle]["T000013"]["pCount"] == 1
- assert theIndex.novelIndex[nHandle]["T000019"]["pCount"] == 3
+ assert theIndex._novelIndex[nHandle]["T000001"]["pCount"] == 1
+ assert theIndex._novelIndex[nHandle]["T000007"]["pCount"] == 1
+ assert theIndex._novelIndex[nHandle]["T000013"]["pCount"] == 1
+ assert theIndex._novelIndex[nHandle]["T000019"]["pCount"] == 3
assert theIndex.scanText(cHandle, (
"# Title One\n\n"
@@ -374,22 +382,22 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI):
"% synopsis: Synopsis One.\n\n"
"Paragraph One.\n\n"
))
- assert theIndex.refIndex[cHandle].get("T000000", None) is not None
- assert theIndex.refIndex[cHandle].get("T000001", None) is not None
- assert theIndex.refIndex[cHandle].get("T000002", None) is None
- assert theIndex.refIndex[cHandle].get("T000003", None) is None
- assert theIndex.refIndex[cHandle].get("T000004", None) is None
- assert theIndex.refIndex[cHandle].get("T000005", None) is None
- assert theIndex.refIndex[cHandle].get("T000006", None) is None
- assert theIndex.refIndex[cHandle].get("T000007", None) is None
+ assert theIndex._refIndex[cHandle].get("T000000", None) is not None
+ assert theIndex._refIndex[cHandle].get("T000001", None) is not None
+ assert theIndex._refIndex[cHandle].get("T000002", None) is None
+ assert theIndex._refIndex[cHandle].get("T000003", None) is None
+ assert theIndex._refIndex[cHandle].get("T000004", None) is None
+ assert theIndex._refIndex[cHandle].get("T000005", None) is None
+ assert theIndex._refIndex[cHandle].get("T000006", None) is None
+ assert theIndex._refIndex[cHandle].get("T000007", None) is None
- assert theIndex.noteIndex[cHandle]["T000001"]["level"] == "H1"
- assert theIndex.noteIndex[cHandle]["T000001"]["title"] == "Title One"
- assert theIndex.noteIndex[cHandle]["T000001"]["layout"] == "NOTE"
- assert theIndex.noteIndex[cHandle]["T000001"]["synopsis"] == "Synopsis One."
- assert theIndex.noteIndex[cHandle]["T000001"]["cCount"] == 23
- assert theIndex.noteIndex[cHandle]["T000001"]["wCount"] == 4
- assert theIndex.noteIndex[cHandle]["T000001"]["pCount"] == 1
+ assert theIndex._noteIndex[cHandle]["T000001"]["level"] == "H1"
+ assert theIndex._noteIndex[cHandle]["T000001"]["title"] == "Title One"
+ assert theIndex._noteIndex[cHandle]["T000001"]["layout"] == "NOTE"
+ assert theIndex._noteIndex[cHandle]["T000001"]["synopsis"] == "Synopsis One."
+ assert theIndex._noteIndex[cHandle]["T000001"]["cCount"] == 23
+ assert theIndex._noteIndex[cHandle]["T000001"]["wCount"] == 4
+ assert theIndex._noteIndex[cHandle]["T000001"]["pCount"] == 1
assert theIndex.scanText(sHandle, (
"# Title One\n\n"
@@ -399,7 +407,7 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI):
"% synopsis: Synopsis One.\n\n"
"Paragraph One.\n\n"
))
- assert theIndex.refIndex[sHandle]["T000001"]["tags"] == (
+ assert theIndex._refIndex[sHandle]["T000001"]["tags"] == (
[[3, "@pov", "One"], [5, "@char", "Two"]]
)
@@ -419,6 +427,9 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI):
nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c")
cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3")
+ assert theIndex.getNovelData("", "") is None
+ assert theIndex.getNovelData("a508bb932959c", "") is None
+
assert theIndex.scanText(cHandle, (
"# Jane Smith\n"
"@tag: Jane\n"
@@ -433,13 +444,32 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI):
))
# The novel structure should contain the pointer to the novel file header
- assert theIndex.getNovelStructure() == ["%s:T000001" % nHandle]
+ theKeys = []
+ for aKey, _, _, _ in theIndex.novelStructure():
+ theKeys.append(aKey)
+
+ assert theKeys == ["%s:T000001" % nHandle]
# Check that excluded files can be skipped
theProject.projTree[nHandle].setExported(False)
- assert theIndex.getNovelStructure(skipExcluded=False) == ["%s:T000001" % nHandle]
- assert theIndex.getNovelStructure(skipExcluded=True) == []
- assert theIndex.getNovelStructure() == []
+
+ theKeys = []
+ for aKey, _, _, _ in theIndex.novelStructure(skipExcluded=False):
+ theKeys.append(aKey)
+
+ assert theKeys == ["%s:T000001" % nHandle]
+
+ theKeys = []
+ for aKey, _, _, _ in theIndex.novelStructure(skipExcluded=True):
+ theKeys.append(aKey)
+
+ assert theKeys == []
+
+ theKeys = []
+ for aKey, _, _, _ in theIndex.novelStructure():
+ theKeys.append(aKey)
+
+ assert theKeys == []
# The novel file should have the correct counts
cC, wC, pC = theIndex.getCounts(nHandle)
diff --git a/tests/test_gui_docviewer.py b/tests/test_gui_docviewer.py
index 3af74afa..235a4c30 100644
--- a/tests/test_gui_docviewer.py
+++ b/tests/test_gui_docviewer.py
@@ -26,11 +26,11 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum):
assert nwGUI.openProject(nwLipsum)
# Rebuild the index as it isn't automatically copied
- assert nwGUI.theIndex.tagIndex == {}
- assert nwGUI.theIndex.refIndex == {}
+ assert nwGUI.theIndex._tagIndex == {}
+ assert nwGUI.theIndex._refIndex == {}
nwGUI.mainMenu.aRebuildIndex.activate(QAction.Trigger)
- assert nwGUI.theIndex.tagIndex != {}
- assert nwGUI.theIndex.refIndex != {}
+ assert nwGUI.theIndex._tagIndex != {}
+ assert nwGUI.theIndex._refIndex != {}
# Select a document in the project tree
assert nwGUI.treeView.setSelectedHandle("88243afbe5ed8")
diff --git a/tests/test_gui_noveltree.py b/tests/test_gui_noveltree.py
new file mode 100644
index 00000000..44b22688
--- /dev/null
+++ b/tests/test_gui_noveltree.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+"""novelWriter Main GUI Project Tree Class Tester
+"""
+
+import pytest
+import os
+
+from tools import writeFile
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import QMessageBox
+
+@pytest.mark.gui
+def testGuiNovelTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal):
+ """Test navigating the novel tree.
+ """
+ # Block message box
+ monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes)
+ monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes)
+
+ nwGUI.openProject(nwMinimal)
+ nwGUI.theProject.projTree.setSeed(42)
+ nwTree = nwGUI.novelView
+
+ ##
+ # Show/Hide Scrollbars
+ ##
+
+ nwTree.mainConf.hideVScroll = True
+ nwTree.mainConf.hideHScroll = True
+ nwTree.initTree()
+ assert not nwTree.verticalScrollBar().isVisible()
+ assert not nwTree.horizontalScrollBar().isVisible()
+
+ nwTree.mainConf.hideVScroll = False
+ nwTree.mainConf.hideHScroll = False
+ nwTree.initTree()
+ assert nwTree.verticalScrollBar().isEnabled()
+ assert nwTree.horizontalScrollBar().isEnabled()
+
+ ##
+ # Populate Tree
+ ##
+
+ nwGUI.projTabs.setCurrentIndex(nwGUI.idxNovelView)
+
+ # The tree should be empty as there is no index
+ assert nwTree.topLevelItemCount() == 0
+
+ nwGUI.rebuildIndex()
+ nwTree._populateTree()
+ assert nwTree.topLevelItemCount() == 1
+
+ # Rebuild should preserve selection
+ topItem = nwTree.topLevelItem(0)
+ assert not topItem.isSelected()
+ topItem.setSelected(True)
+ assert nwTree.selectedItems()[0] == topItem
+ assert nwTree.getSelectedHandle() == "a35baf2e93843"
+
+ nwTree.refreshTree()
+ assert nwTree.topLevelItem(0).isSelected()
+
+ ##
+ # Open Items
+ ##
+
+ # Clear selection
+ nwTree.clearSelection()
+ scItem = nwTree.topLevelItem(0).child(0).child(0)
+ scItem.setSelected(True)
+ assert scItem.isSelected()
+
+ # Clear selection with mouse
+ vPort = nwTree.viewport()
+ qtbot.mouseClick(vPort, Qt.LeftButton, pos=vPort.rect().center(), delay=10)
+ assert not scItem.isSelected()
+
+ # Double-click item
+ scItem.setSelected(True)
+ assert scItem.isSelected()
+ assert nwGUI.docEditor.theHandle is None
+ nwTree._treeDoubleClick(scItem, 0)
+ assert nwGUI.docEditor.theHandle == "8c659a11cd429"
+
+ # Open item with middle mouse button
+ scItem.setSelected(True)
+ assert scItem.isSelected()
+ assert nwGUI.docViewer.theHandle is None
+ qtbot.mouseClick(vPort, Qt.MiddleButton, pos=vPort.rect().center(), delay=10)
+ assert nwGUI.docViewer.theHandle is None
+
+ scRect = nwTree.visualItemRect(scItem)
+ oldData = scItem.data(nwTree.C_TITLE, Qt.UserRole)
+ scItem.setData(nwTree.C_TITLE, Qt.UserRole, (None, "", ""))
+ qtbot.mouseClick(vPort, Qt.MiddleButton, pos=scRect.center(), delay=10)
+ assert nwGUI.docViewer.theHandle is None
+
+ scItem.setData(nwTree.C_TITLE, Qt.UserRole, oldData)
+ qtbot.mouseClick(vPort, Qt.MiddleButton, pos=scRect.center(), delay=10)
+ assert nwGUI.docViewer.theHandle == "8c659a11cd429"
+
+ ##
+ # Populate Tree
+ ##
+
+ # Add weird titles to first file to check hnadling of non-standard
+ # order of title levels.
+ writeFile(os.path.join(nwMinimal, "content", "a35baf2e93843.nwd"), (
+ "#### Section wo/Scene\n\n"
+ "### Scene wo/Chapter\n\n"
+ "## Chapter wo/Title\n\n"
+ "# Title\n\n"
+ "#### Section w/Title, wo/Scene\n\n"
+ "### Scene w/Title, wo/Chapter\n\n"
+ "## Chapter\n\n"
+ "#### Section w/Chapter, wo/Scene\n\n"
+ "### Scene\n\n"
+ "#### Section\n\n"
+ ))
+ nwGUI.rebuildIndex()
+ nwTree._populateTree()
+ assert nwTree.topLevelItem(0).text(nwTree.C_TITLE) == "Section wo/Scene"
+ assert nwTree.topLevelItem(1).text(nwTree.C_TITLE) == "Scene wo/Chapter"
+ assert nwTree.topLevelItem(2).text(nwTree.C_TITLE) == "Chapter wo/Title"
+ assert nwTree.topLevelItem(3).text(nwTree.C_TITLE) == "Title"
+
+ tTitle = nwTree.topLevelItem(3)
+ assert tTitle.child(0).text(nwTree.C_TITLE) == "Section w/Title, wo/Scene"
+ assert tTitle.child(1).text(nwTree.C_TITLE) == "Scene w/Title, wo/Chapter"
+ assert tTitle.child(2).text(nwTree.C_TITLE) == "Chapter"
+
+ tChap = tTitle.child(2)
+ assert tChap.child(0).text(nwTree.C_TITLE) == "Section w/Chapter, wo/Scene"
+ assert tChap.child(1).text(nwTree.C_TITLE) == "Scene"
+
+ tScene = tChap.child(1)
+ assert tScene.child(0).text(nwTree.C_TITLE) == "Section"
+
+ ##
+ # Close
+ ##
+
+ # qtbot.stopForInteraction()
+ nwGUI.closeProject()
+
+# END Test testGuiNovelTree_TreeItems
diff --git a/tests/test_gui_outline.py b/tests/test_gui_outline.py
index cf93c111..906481c5 100644
--- a/tests/test_gui_outline.py
+++ b/tests/test_gui_outline.py
@@ -24,7 +24,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum):
nwGUI.mainConf.lastPath = nwLipsum
nwGUI.rebuildIndex()
- nwGUI.tabWidget.setCurrentIndex(nwGUI.idxTabProj)
+ nwGUI.mainTabs.setCurrentIndex(nwGUI.idxTabProj)
assert nwGUI.projView.topLevelItemCount() > 0
diff --git a/tests/test_gui_preferences.py b/tests/test_gui_preferences.py
index 4c8b17ea..9c76d830 100644
--- a/tests/test_gui_preferences.py
+++ b/tests/test_gui_preferences.py
@@ -220,10 +220,11 @@ def testGuiPreferences_Main(qtbot, monkeypatch, fncDir, outDir, refDir):
compFile = os.path.join(refDir, "guiPreferences_novelwriter.conf")
copyfile(projFile, testFile)
ignoreLines = [
- 2, # Timestamp
- 9, # Release Notes
- 12, 13, 14, 15, 16, 17, 18, # Window sizes
- 7, 28, # Fonts (depends on system default)
+ 2, # Timestamp
+ 9, # Release Notes
+ 12, 13, 14, 15, # Window sizes
+ 16, 17, 18, 19, # Window sizes
+ 7, 29, # Fonts (depends on system default)
]
assert cmpFiles(testFile, compFile, ignoreLines)