diff --git a/.gitignore b/.gitignore index f772cce6..54fe2f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ sample/**/wordlist.txt sample/**/sessionInfo.log sample/**/*.bak sample/**/*.json +sample/**/*.lock # PyTest tests/temp diff --git a/nw/config.py b/nw/config.py index ada60d85..5c8388b4 100644 --- a/nw/config.py +++ b/nw/config.py @@ -116,6 +116,7 @@ class Config: self.showTabsNSpaces = False self.showLineEndings = False self.bigDocLimit = 800 + self.showFullPath = True self.fmtApostrophe = nwUnicode.U_RSQUO self.fmtSingleQuotes = [nwUnicode.U_LSQUO,nwUnicode.U_RSQUO] @@ -398,6 +399,9 @@ class Config: self.bigDocLimit = self._parseLine( cnfParse, cnfSec, "bigdoclimit", self.CNF_INT, self.bigDocLimit ) + self.showFullPath = self._parseLine( + cnfParse, cnfSec, "showfullpath", self.CNF_BOOL, self.showFullPath + ) ## Backup cnfSec = "Backup" @@ -486,6 +490,7 @@ class Config: cnfParse.set(cnfSec,"showtabsnspaces", str(self.showTabsNSpaces)) cnfParse.set(cnfSec,"showlineendings", str(self.showLineEndings)) cnfParse.set(cnfSec,"bigdoclimit", str(self.bigDocLimit)) + cnfParse.set(cnfSec,"showfullpath", str(self.showFullPath)) ## Backup cnfSec = "Backup" diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 643c3a37..3f6192d4 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -234,6 +234,12 @@ class nwUnicode: U_NBSP = "\u00a0" # Non-breaking space U_PARA = "\u2029" # Paragraph separator + ## Arrows + U_UTRI = "\u2bc5" # Up-pointing triangle + U_DTRI = "\u2bc6" # Down-pointing triangle + U_LTRI = "\u2bc7" # Left-pointing triangle + U_RTRI = "\u2bc8" # Right-pointing triangle + # HTML Equivalents ## Quotes @@ -265,4 +271,10 @@ class nwUnicode: ## Other H_NBSP = " " + ## Arrows + H_UTRI = "⯅" + H_DTRI = "⯆" + H_LTRI = "⯇" + H_RTRI = "⯈" + # END Class nwUnicode diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py index 87dec78c..d5f7e67b 100644 --- a/nw/gui/__init__.py +++ b/nw/gui/__init__.py @@ -19,6 +19,7 @@ from nw.gui.dialogs.sessionlog import GuiSessionLogView # GUI Elements from nw.gui.elements.docdetails import GuiDocDetails from nw.gui.elements.doceditor import GuiDocEditor +from nw.gui.elements.doctitlebar import GuiDocTitleBar from nw.gui.elements.doctree import GuiDocTree from nw.gui.elements.docviewer import GuiDocViewer from nw.gui.elements.noticebar import GuiNoticeBar @@ -45,6 +46,7 @@ __all__ = [ "GuiSessionLogView", "GuiDocDetails", "GuiDocEditor", + "GuiDocTitleBar", "GuiDocTree", "GuiDocViewer", "GuiNoticeBar", diff --git a/nw/gui/elements/__init__.py b/nw/gui/elements/__init__.py index 1d885de0..b0938816 100644 --- a/nw/gui/elements/__init__.py +++ b/nw/gui/elements/__init__.py @@ -2,6 +2,7 @@ from nw.gui.elements.docdetails import GuiDocDetails from nw.gui.elements.doceditor import GuiDocEditor +from nw.gui.elements.doctitlebar import GuiDocTitleBar from nw.gui.elements.doctree import GuiDocTree from nw.gui.elements.docviewer import GuiDocViewer from nw.gui.elements.noticebar import GuiNoticeBar @@ -12,6 +13,7 @@ from nw.gui.elements.viewdetails import GuiDocViewDetails __all__ = [ "GuiDocDetails", "GuiDocEditor", + "GuiDocTitleBar", "GuiDocTree", "GuiDocViewer", "GuiNoticeBar", diff --git a/nw/gui/elements/doceditor.py b/nw/gui/elements/doceditor.py index 2ad49a1b..fb76ff67 100644 --- a/nw/gui/elements/doceditor.py +++ b/nw/gui/elements/doceditor.py @@ -32,7 +32,7 @@ from time import time from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( - qApp, QTextEdit, QAction, QMenu, QShortcut, QMessageBox + qApp, QTextEdit, QAction, QMenu, QShortcut, QMessageBox, QLabel ) from PyQt5.QtGui import ( QTextCursor, QTextOption, QKeySequence, QFont, QColor, QPalette, @@ -41,6 +41,7 @@ from PyQt5.QtGui import ( from nw.project import NWDoc from nw.gui.tools import GuiDocHighlighter, WordCounter +from nw.gui.elements.doctitlebar import GuiDocTitleBar from nw.tools import NWSpellSimple from nw.constants import nwUnicode, nwDocAction @@ -82,6 +83,10 @@ class GuiDocEditor(QTextEdit): self.qDocument.setDocumentMargin(self.mainConf.textMargin) self.qDocument.contentsChange.connect(self._docChange) + # Document Title + self.docTitle = GuiDocTitleBar(self, self.theProject) + self.docTitle.setGeometry(0,0,self.docTitle.width(),self.docTitle.height()) + # Syntax self.hLight = GuiDocHighlighter(self.qDocument, self.theParent) @@ -127,11 +132,14 @@ class GuiDocEditor(QTextEdit): logger.debug("DocEditor initialisation complete") + # Connect Functions + self.setSelectedHandle = self.theParent.treeView.setSelectedHandle + return def clearEditor(self): """Clear the current document and reset all document related - flags and counters. + flags and counters. """ self.nwDocument.clearDocument() @@ -150,6 +158,7 @@ class GuiDocEditor(QTextEdit): self.setDocumentChanged(False) self.theParent.noticeBar.hideNote() + self.docTitle.setTitleFromHandle(self.theHandle) return True @@ -264,6 +273,7 @@ class GuiDocEditor(QTextEdit): else: self.theParent.noticeBar.showNote("This document is read only.") + self.docTitle.setTitleFromHandle(self.theHandle) self.hLight.spellCheck = spTemp qApp.restoreOverrideCursor() @@ -319,9 +329,16 @@ class GuiDocEditor(QTextEdit): else: tM = self.mainConf.textMargin + tB = self.lineWidth() + tW = self.width() - 2*tB + tH = self.docTitle.height() + self.docTitle.setGeometry(tB, tB, tW, tH) + docFormat = self.qDocument.rootFrame().frameFormat() docFormat.setLeftMargin(tM) docFormat.setRightMargin(tM) + if docFormat.topMargin() < tH: + docFormat.setTopMargin(tH + 2) # Updating root frame triggers a QTextDocument->contentsChange # signal, which we do not want as it re-runs the syntax diff --git a/nw/gui/elements/doctitlebar.py b/nw/gui/elements/doctitlebar.py new file mode 100644 index 00000000..b8d5d739 --- /dev/null +++ b/nw/gui/elements/doctitlebar.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""novelWriter GUI Document Title Bar + + novelWriter – GUI Document Title Bar +====================================== + Class holding the document title bar class + + File History: + Created: 2020-04-25 [0.4.5] + + This file is a part of novelWriter + Copyright 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 https://www.gnu.org/licenses/. +""" + +import logging +import nw + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPalette, QColor +from PyQt5.QtWidgets import QLabel, QFrame, QStyle + +from nw.constants import nwUnicode + +logger = logging.getLogger(__name__) + +class GuiDocTitleBar(QLabel): + + def __init__(self, theParent, theProject): + QLabel.__init__(self, theParent) + + logger.debug("Initialising DocTitleBar ...") + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theProject = theProject + self.theTheme = theParent.theTheme + self.theHandle = None + + self.setText("") + self.setIndent(0) + self.setMargin(0) + self.setContentsMargins(0,0,0,0) + self.setAutoFillBackground(True) + self.setAlignment(Qt.AlignCenter) + self.setWordWrap(True) + self.setFrameShape(QFrame.NoFrame) + self.setLineWidth(0) + lblPalette = self.palette() + lblPalette.setColor(QPalette.Window, QColor(*self.theTheme.colBack)) + lblPalette.setColor(QPalette.Text, QColor(*self.theTheme.colText)) + self.setPalette(lblPalette) + + lblFont = self.font() + lblFont.setPointSizeF(0.9*self.theTheme.defFontSize) + self.setFont(lblFont) + + logger.debug("DocTitleBar initialisation complete") + + return + + ## + # Setters + ## + + def setTitleFromHandle(self, tHandle): + """Sets the document title from the handle, or alternatively, + set the whole document path. + """ + + self.setText("") + self.theHandle = tHandle + if tHandle is None: + return False + + if self.mainConf.showFullPath: + tTitle = [] + tTree = self.theProject.getItemPath(tHandle) + for aHandle in reversed(tTree): + nwItem = self.theProject.getItem(aHandle) + if nwItem is not None: + tTitle.append(nwItem.itemName) + sSep = " %s " % nwUnicode.U_RTRI + self.setText(sSep.join(tTitle)) + else: + nwItem = self.theProject.getItem(tHandle) + if nwItem is None: + return False + + self.setText(nwItem.itemName) + + return True + + ## + # Events + ## + + def mousePressEvent(self, theEvent): + """Capture a click on the title and ensure that the item is + selected in the project tree. + """ + self.theParent.setSelectedHandle(self.theHandle) + return + +# END Class GuiDocTitleBar diff --git a/nw/gui/elements/doctree.py b/nw/gui/elements/doctree.py index 02ad9155..47c5c276 100644 --- a/nw/gui/elements/doctree.py +++ b/nw/gui/elements/doctree.py @@ -457,6 +457,9 @@ class GuiDocTree(QTreeWidget): return True def getSelectedHandle(self): + """Get the currently selected handle. If multiple items are + selected, return the first. + """ selItem = self.selectedItems() if len(selItem) == 0: return None @@ -465,6 +468,8 @@ class GuiDocTree(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)): @@ -472,6 +477,15 @@ class GuiDocTree(QTreeWidget): selHandles.append(selItems[n].text(self.C_HANDLE)) return selHandles + def setSelectedHandle(self, tHandle): + """Set a specific handle as the selected item. + """ + if tHandle in self.theMap: + self.clearSelection() + self.theMap[tHandle].setSelected(True) + return True + return False + ## # Internal Functions ## diff --git a/nw/gui/elements/docviewer.py b/nw/gui/elements/docviewer.py index 178a54cf..84f4a8fd 100644 --- a/nw/gui/elements/docviewer.py +++ b/nw/gui/elements/docviewer.py @@ -34,6 +34,7 @@ from PyQt5.QtGui import QTextOption, QFont, QPalette, QColor, QTextCursor from nw.convert import ToHtml from nw.constants import nwAlert, nwItemType, nwDocAction +from nw.gui.elements.doctitlebar import GuiDocTitleBar logger = logging.getLogger(__name__) @@ -56,6 +57,10 @@ class GuiDocViewer(QTextBrowser): self.setOpenExternalLinks(False) self.initViewer() + # Document Title + self.docTitle = GuiDocTitleBar(self, self.theProject) + self.docTitle.setGeometry(0,0,self.docTitle.width(),self.docTitle.height()) + theOpt = QTextOption() if self.mainConf.doJustify: theOpt.setAlignment(Qt.AlignJustify) @@ -66,11 +71,18 @@ class GuiDocViewer(QTextBrowser): logger.debug("DocViewer initialisation complete") + # Connect Functions + self.setSelectedHandle = self.theParent.treeView.setSelectedHandle + return def clearViewer(self): + """Clear the content of the document and reset key variables. + """ self.clear() self.setSearchPaths([""]) + self.theHandle = None + self.docTitle.setTitleFromHandle(self.theHandle) return True def initViewer(self): @@ -108,6 +120,8 @@ class GuiDocViewer(QTextBrowser): return True def loadText(self, tHandle): + """Load text into the viewer from an item handle. + """ tItem = self.theProject.getItem(tHandle) if tItem is None: @@ -131,7 +145,9 @@ class GuiDocViewer(QTextBrowser): self.verticalScrollBar().setValue(sPos) self.theHandle = tHandle self.theProject.setLastViewed(tHandle) + self.docTitle.setTitleFromHandle(self.theHandle) + # Make sure the main GUI knows we changed the content self.theParent.viewMeta.refreshReferences(tHandle) return True @@ -176,6 +192,26 @@ class GuiDocViewer(QTextBrowser): return False return True + ## + # Events + ## + + def resizeEvent(self, theEvent): + """Make sure the document title is the same width as the window. + """ + QTextBrowser.resizeEvent(self, theEvent) + + tB = self.lineWidth() + tW = self.width() - 2*tB + tH = self.docTitle.height() + self.docTitle.setGeometry(tB, tB, tW, tH) + + docFormat = self.qDocument.rootFrame().frameFormat() + if docFormat.topMargin() < tH: + docFormat.setTopMargin(tH + 2) + + return + ## # Internal Functions ## diff --git a/nw/gui/elements/noticebar.py b/nw/gui/elements/noticebar.py index c423e93b..c9022262 100644 --- a/nw/gui/elements/noticebar.py +++ b/nw/gui/elements/noticebar.py @@ -49,7 +49,7 @@ class GuiNoticeBar(QFrame): self.mainBox = QHBoxLayout(self) self.mainBox.setContentsMargins(8,2,2,2) - self.noteLabel = QLabel("Hi there!") + self.noteLabel = QLabel("") self.closeButton = QPushButton(self.theTheme.getIcon("close"),"") self.closeButton.clicked.connect(self.hideNote) diff --git a/nw/gui/theme.py b/nw/gui/theme.py index 95c318d8..00463aac 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -110,6 +110,10 @@ class GuiTheme: self.getPixmap = self.theIcons.getPixmap self.loadDecoration = self.theIcons.loadDecoration + # Extract Other Info + self.defFont = qApp.font() + self.defFontSize = self.defFont.pointSizeF() + return ## diff --git a/nw/guimain.py b/nw/guimain.py index 46a32384..0b6ac0a5 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -65,6 +65,7 @@ class GuiMain(QMainWindow): self.hasProject = False self.isZenMode = False + # Some runtime info useful for debugging logger.info("OS: %s" % self.mainConf.osType) logger.info("Kernel: %s" % self.mainConf.kernelVer) logger.info("Host: %s" % self.mainConf.hostName) @@ -78,19 +79,23 @@ class GuiMain(QMainWindow): self.mainConf.verPyString, self.mainConf.verPyHexVal) ) + # Prepare main window self.resize(*self.mainConf.winGeometry) self._setWindowTitle() self.setWindowIcon(QIcon(self.mainConf.appIcon)) + # Build the GUI + ################ + # Main GUI Elements self.statusBar = GuiMainStatus(self) self.noticeBar = GuiNoticeBar(self) + self.treeView = GuiDocTree(self, self.theProject) self.docEditor = GuiDocEditor(self, self.theProject) self.docViewer = GuiDocViewer(self, self.theProject) self.viewMeta = GuiDocViewDetails(self, self.theProject) self.searchBar = GuiSearchBar(self) self.treeMeta = GuiDocDetails(self, self.theProject) - self.treeView = GuiDocTree(self, self.theProject) self.projView = GuiProjectOutline(self, self.theProject) self.mainMenu = GuiMainMenu(self, self.theProject) @@ -109,6 +114,7 @@ class GuiMain(QMainWindow): self.editPane = QFrame() self.docEdit = QVBoxLayout() self.docEdit.setContentsMargins(0,0,0,0) + self.docEdit.setSpacing(2) self.docEdit.addWidget(self.searchBar) self.docEdit.addWidget(self.noticeBar) self.docEdit.addWidget(self.docEditor) @@ -117,6 +123,7 @@ class GuiMain(QMainWindow): self.viewPane = QFrame() self.docView = QVBoxLayout() self.docView.setContentsMargins(0,0,0,0) + self.docView.setSpacing(2) self.docView.addWidget(self.docViewer) self.docView.addWidget(self.viewMeta) self.docView.setStretch(0, 1) @@ -162,7 +169,7 @@ class GuiMain(QMainWindow): self.viewPane.setVisible(False) self.searchBar.setVisible(False) - # Build The Tree View + # Build the Tree View self.treeView.itemSelectionChanged.connect(self._treeSingleClick) self.treeView.itemDoubleClicked.connect(self._treeDoubleClick) self.rebuildTree() @@ -172,6 +179,9 @@ class GuiMain(QMainWindow): self.setStatusBar(self.statusBar) self.statusBar.setStatus("Ready") + # Finalise Initialisation + ########################## + # Set Up Autosaving Project Timer self.asProjTimer = QTimer() self.asProjTimer.timeout.connect(self._autoSaveProject) @@ -210,6 +220,8 @@ class GuiMain(QMainWindow): logger.debug("GUI initialisation complete") + # Check if a project path was provided at command line, and if + # not, open the project manager instead. if self.mainConf.cmdOpen is not None: logger.debug("Opening project from additional command line option") self.openProject(self.mainConf.cmdOpen) @@ -228,6 +240,8 @@ class GuiMain(QMainWindow): return True def initMain(self): + """Initialise elements that depend on user settings. + """ self.asProjTimer.setInterval(int(self.mainConf.autoSaveProj*1000)) self.asDocTimer.setInterval(int(self.mainConf.autoSaveDoc*1000)) return True @@ -437,6 +451,8 @@ class GuiMain(QMainWindow): return True def backupProject(self): + """Trigger the project backup process. + """ theBackup = NWBackup(self, self.theProject) theBackup.zipIt() return True @@ -446,6 +462,8 @@ class GuiMain(QMainWindow): ## def closeDocument(self): + """Close the document and clear the editor and title field. + """ if self.hasProject: if self.docEditor.docChanged: self.saveDocument() @@ -461,6 +479,7 @@ class GuiMain(QMainWindow): if self.docEditor.loadText(tHandle, tLine): self.docEditor.setFocus() self.theProject.setLastEdited(tHandle) + self.treeView.setSelectedHandle(tHandle) else: return False return True @@ -471,6 +490,8 @@ class GuiMain(QMainWindow): return True def viewDocument(self, tHandle=None): + """Load a document for viewing in the view panel. + """ if tHandle is None: tHandle = self.treeView.getSelectedHandle() @@ -498,6 +519,9 @@ class GuiMain(QMainWindow): return True def importDocument(self): + """Import the text contained in an out-of-project text file, and + insert the text into the currently open document. + """ lastPath = self.mainConf.lastPath diff --git a/nw/project/project.py b/nw/project/project.py index aa63649a..ef09f671 100644 --- a/nw/project/project.py +++ b/nw/project/project.py @@ -601,6 +601,9 @@ class NWProject(): ## def getItem(self, tHandle): + """Return a project item based on its handle. Returns None if + the handle doesn't exist in the project. + """ if tHandle in self.projTree: return self.projTree[tHandle] logger.error("No tree item with handle %s" % str(tHandle)) @@ -626,6 +629,28 @@ class NWProject(): return tHandle return None + def getItemPath(self, tHandle): + """Iterate upwards in the tree until we find the item with + parent None, the root item, and return the list of handles. + We do this with a for loop with a maximum depth of 200 to make + infinite loops impossible. + """ + tTree = [] + tItem = self.getItem(tHandle) + if tItem is not None: + tTree.append(tHandle) + for i in range(200): + if tItem.parHandle is None: + return tTree + else: + tHandle = tItem.parHandle + tItem = self.getItem(tHandle) + if tItem is None: + return tTree + else: + tTree.append(tHandle) + return tTree + def getProjectItems(self): """This function is called from the tree view when building the tree. Each item in the project is returned in the order saved in diff --git a/tests/reference/novelwriter.conf b/tests/reference/novelwriter.conf index ad20b354..7102783d 100644 --- a/tests/reference/novelwriter.conf +++ b/tests/reference/novelwriter.conf @@ -1,5 +1,5 @@ [Main] -timestamp = 2020-04-14 22:47:16 +timestamp = 2020-05-01 21:28:25 theme = default syntax = default_light guidark = False @@ -38,6 +38,7 @@ spellcheck = en showtabsnspaces = False showlineendings = False bigdoclimit = 800 +showfullpath = True [Backup] backuppath =