diff --git a/README.md b/README.md index 12126c70..775babe0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,10 @@ There are no launcher icons yet. Consult your operating system documentation for how to make those. These will be added at some point, and I would appreciate any assistance from people working on Windows and MacOS as I don't use either of those operating systems. -## Dependencies +## Package Dependencies + +It is recommended that novelWriter runs with Qt 5.9 or later, and Python 3.6 or later. +Running with Qt as low as 5.2.1 and Python 3.4.3 has been tested, and worked in the past, but there are no guarantees that this will keep working as these are not a part of the test builds. For the apt package manager on Debian systems, the following Python3 packages are needed: diff --git a/novelWriter.py b/novelWriter.py index 3aa88038..1d4b711a 100755 --- a/novelWriter.py +++ b/novelWriter.py @@ -3,10 +3,6 @@ import sys -if sys.hexversion < 0x030600F0: - print("ERROR: At least Python 3.6 is required") - sys.exit(1) - try: import PyQt5.QtWidgets import PyQt5.QtGui @@ -15,18 +11,6 @@ except: print("ERROR: Failed to load dependency python3-pyqt5") sys.exit(1) -try: - import PyQt5.QtSvg -except: - print("ERROR: Failed to load dependency python3-pyqt5.qtsvg") - sys.exit(1) - -try: - import lxml -except: - print("ERROR: Failed to load dependency python3-lxml") - sys.exit(1) - if __name__ == "__main__": import nw nw.main(sys.argv[1:]) diff --git a/nw/__init__.py b/nw/__init__.py index 31c19ee5..9d27b993 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -32,9 +32,8 @@ import logging from os import path, remove, rename from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QApplication, QErrorMessage -from nw.guimain import GuiMain from nw.config import Config __package__ = "novelWriter" @@ -67,7 +66,7 @@ __credits__ = [ # VERBOSE Use for outputting values and program flow details # -# Adding verbose logging levels +# Add verbose logging level VERBOSE = 5 logging.addLevelName(VERBOSE, "VERBOSE") def logVerbose(self, message, *args, **kws): @@ -217,8 +216,48 @@ def main(sysArgs=None): logger.setLevel(debugLevel) + # Check Packages and Versions + errorData = [] + if sys.hexversion < 0x030403F0: + errorData.append( + "At least Python 3.4.3 is required, but 3.6 is highly recommended." + ) + if CONFIG.verQtValue < 50200: + errorData.append( + "At least Qt5 version 5.2 is required, found %s." % CONFIG.verQtString + ) + if CONFIG.verPyQtValue < 50200: + errorData.append( + "At least PyQt5 version 5.2 is required, found %s." % CONFIG.verPyQtString + ) + try: + import PyQt5.QtSvg + except: + errorData.append("Python module 'PyQt5.QtSvg' is missing.") + try: + import lxml + except: + errorData.append("Python module 'lxml' is missing.") + + if errorData: + errApp = QApplication([]) + errMsg = QErrorMessage() + errMsg.setMinimumWidth(500) + errMsg.setMinimumHeight(300) + errMsg.showMessage(( + "ERROR: %s cannot start due to the following issues:

" + " - %s

Exiting." + ) % ( + __package__, "
 - ".join(errorData) + )) + errApp.exec_() + sys.exit(1) + + # Finish initialising config CONFIG.initConfig(confPath, dataPath) + # Import GUI (after dependency checks), and launch + from nw.guimain import GuiMain if testMode: nwGUI = GuiMain() return nwGUI diff --git a/nw/assets/icons/fallback/status_stats-dark.svg b/nw/assets/icons/fallback/status_stats-dark.svg new file mode 100644 index 00000000..81fd5b96 --- /dev/null +++ b/nw/assets/icons/fallback/status_stats-dark.svg @@ -0,0 +1,56 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/nw/assets/icons/fallback/status_stats.svg b/nw/assets/icons/fallback/status_stats.svg new file mode 100644 index 00000000..a6727601 --- /dev/null +++ b/nw/assets/icons/fallback/status_stats.svg @@ -0,0 +1,56 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/nw/config.py b/nw/config.py index 81e87961..00454b35 100644 --- a/nw/config.py +++ b/nw/config.py @@ -36,6 +36,7 @@ from time import time from PyQt5.Qt import PYQT_VERSION_STR from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths, QSysInfo +from PyQt5.QtWidgets import QErrorMessage from nw.constants import nwFiles, nwUnicode from nw.common import splitVersionNumber, formatTimeStamp @@ -176,8 +177,12 @@ class Config: self.osUnknown = True # Other System Info - self.hostName = QSysInfo.machineHostName() - self.kernelVer = QSysInfo.kernelVersion() + if self.verQtValue >= 50600: + self.hostName = QSysInfo.machineHostName() + self.kernelVer = QSysInfo.kernelVersion() + else: + self.hostName = "Unknown" + self.kernelVer = "Unknown" # Packages self.hasEnchant = False diff --git a/nw/core/document.py b/nw/core/document.py index aaef4e9e..6bcba588 100644 --- a/nw/core/document.py +++ b/nw/core/document.py @@ -73,6 +73,8 @@ class NWDoc(): """Open a document from handle, capturing potential file system errors and parse meta data. """ + # Always clear first, since the object will often be reused. + self.clearDocument() self.docHandle = tHandle if not isOrphan: diff --git a/nw/core/project.py b/nw/core/project.py index 781ad7c7..55c69950 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -975,7 +975,6 @@ class NWProject(): oName = "" if aDoc.openDocument(oHandle, showStatus=False, isOrphan=True): oName, oPath = aDoc.getMeta() - aDoc.clearDocument() if oName == "": nOrph += 1 diff --git a/nw/gui/dialogs/projecteditor.py b/nw/gui/dialogs/projecteditor.py index 6e8b63be..ff6171cd 100644 --- a/nw/gui/dialogs/projecteditor.py +++ b/nw/gui/dialogs/projecteditor.py @@ -296,7 +296,7 @@ class GuiProjectEditStatus(QWidget): newItem.setIcon(QIcon(newIcon)) newItem.setData(Qt.UserRole, len(self.colData)) self.listBox.addItem(newItem) - self.colData.append((iName,*iCol,oName)) + self.colData.append((iName,iCol[0],iCol[1],iCol[2],oName)) self.colCounts.append(nUse) return newItem diff --git a/nw/gui/elements/doceditor.py b/nw/gui/elements/doceditor.py index 3549dbb7..5e5e6228 100644 --- a/nw/gui/elements/doceditor.py +++ b/nw/gui/elements/doceditor.py @@ -216,18 +216,23 @@ class GuiDocEditor(QTextEdit): # font changed, otherwise we just clear the editor entirely, # which makes it read only. if self.theHandle is not None: - # We must save the current handle as clearEditor() sets it - # to None - tHandle = self.theHandle - self.clearEditor() - self.loadText(tHandle) - self.updateDocMargins() + self.reloadText() else: self.clearEditor() return True - def loadText(self, tHandle, tLine=None): + def reloadText(self): + """Reloads the document currently being edited. + """ + if self.theHandle is not None: + tHandle = self.theHandle + self.clearEditor() + self.loadText(tHandle, showStatus=False) + self.updateDocMargins() + return + + def loadText(self, tHandle, tLine=None, showStatus=True): """Load text from a document into the editor. If we have an io error, we must handle this and clear the editor so that we don't risk overwriting the file if it exists. This can for instance @@ -237,7 +242,7 @@ class GuiDocEditor(QTextEdit): the file. """ - theDoc = self.nwDocument.openDocument(tHandle) + theDoc = self.nwDocument.openDocument(tHandle, showStatus=showStatus) if theDoc is None: # There was an io error self.clearEditor() @@ -257,7 +262,6 @@ class GuiDocEditor(QTextEdit): self.setPlainText(theDoc) afTime = time() logger.debug("Document highlighted in %.3f milliseconds" % (1000*(afTime-bfTime))) - self.updateDocMargins() if tLine is None: self.setCursorPosition(self.nwDocument.theItem.cursorPos) @@ -275,6 +279,7 @@ class GuiDocEditor(QTextEdit): self.theParent.noticeBar.showNote("This document is read only.") self.docTitle.setTitleFromHandle(self.theHandle) + self.updateDocMargins() self.hLight.spellCheck = spTemp qApp.restoreOverrideCursor() @@ -354,7 +359,7 @@ class GuiDocEditor(QTextEdit): # The line below causes issues with large documents as it # triggers an early repaint that seems to only render a part of # the document. Leaving it here as a warning for now. - # self.qDocument.contentsChange.emit(0,0,0) + # self.qDocument.contentsChange.emit(0, 0, 0) return diff --git a/nw/gui/icons.py b/nw/gui/icons.py index 35bc8122..c1a791d1 100644 --- a/nw/gui/icons.py +++ b/nw/gui/icons.py @@ -60,6 +60,7 @@ class GuiIcons: "proj_folder" : (QStyle.SP_DirIcon, "folder"), "status_lang" : (None, None), "status_time" : (None, None), + "status_stats" : (None, None), ## Button Icons "folder-open" : (QStyle.SP_DirOpenIcon, "folder-open"), "delete" : (QStyle.SP_DialogDiscardButton, "edit-delete"), diff --git a/nw/gui/statusbar.py b/nw/gui/statusbar.py index 50f218f5..6d104357 100644 --- a/nw/gui/statusbar.py +++ b/nw/gui/statusbar.py @@ -31,10 +31,11 @@ import nw from time import time from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QColor, QPixmap, QFont -from PyQt5.QtWidgets import QStatusBar, QLabel +from PyQt5.QtGui import QColor, QPixmap, QFont, QPainter +from PyQt5.QtWidgets import qApp, QStatusBar, QLabel, QAbstractButton from nw.core import NWSpellCheck +from nw.common import formatInt logger = logging.getLogger(__name__) @@ -49,59 +50,71 @@ class GuiMainStatus(QStatusBar): self.theParent = theParent self.refTime = None - self.iconGrey = QPixmap(16,16) - self.iconYellow = QPixmap(16,16) - self.iconGreen = QPixmap(16,16) + self.charCount = 0 + self.wordCount = 0 + self.paraCount = 0 + self.projWords = 0 + self.sessWords = 0 self.monoFont = QFont("Monospace",10) - self.iconGrey.fill(QColor(*self.theParent.theTheme.statNone)) - self.iconYellow.fill(QColor(*self.theParent.theTheme.statUnsaved)) - self.iconGreen.fill(QColor(*self.theParent.theTheme.statSaved)) + colNone = QColor(*self.theParent.theTheme.statNone) + colTrue = QColor(*self.theParent.theTheme.statUnsaved) + colFalse = QColor(*self.theParent.theTheme.statSaved) - self.boxStats = QLabel() - self.boxStats.setToolTip("Project Word Count | Session Word Count") + # Permanent Widgets + # ================= - self.timeBox = QLabel("") - self.timeBox.setToolTip("Session Time") - self.timeBox.setAlignment(Qt.AlignVCenter | Qt.AlignRight) - self.timeBox.setFont(self.monoFont) - - self.timeIcon = QLabel() - self.timeIcon.setPixmap(self.theParent.theTheme.getPixmap("status_time",(14,14))) - - self.boxCounts = QLabel() - self.boxCounts.setToolTip("Document Character | Word | Paragraph Count") - - self.projChanged = QLabel("") - self.projChanged.setFixedHeight(14) - self.projChanged.setFixedWidth(14) - self.projChanged.setToolTip("Project Changes Saved") - - self.docChanged = QLabel("") - self.docChanged.setFixedHeight(14) - self.docChanged.setFixedWidth(14) - self.docChanged.setToolTip("Document Changes Saved") - - self.langBox = QLabel("None") + ## The Spell Checker Language self.langIcon = QLabel("") + self.langText = QLabel("None") self.langIcon.setPixmap(self.theParent.theTheme.getPixmap("status_lang",(14,14))) - - # Add Them + self.langIcon.setContentsMargins(0, 0, 0, 0) + self.langText.setContentsMargins(0, 0, 8, 0) self.addPermanentWidget(self.langIcon) - self.addPermanentWidget(self.langBox) - self.addPermanentWidget(QLabel(" ")) - self.addPermanentWidget(self.docChanged) - self.addPermanentWidget(self.boxCounts) - self.addPermanentWidget(QLabel(" ")) - self.addPermanentWidget(self.projChanged) - self.addPermanentWidget(self.boxStats) - self.addPermanentWidget(QLabel(" ")) - self.addPermanentWidget(self.timeIcon) - self.addPermanentWidget(self.timeBox) + self.addPermanentWidget(self.langText) + ## The Editor Status + self.docIcon = StatusLED(colNone, colTrue, colFalse, 14, 14, self) + self.docText = QLabel("Editor") + self.docIcon.setContentsMargins(0, 0, 0, 0) + self.docText.setContentsMargins(0, 0, 8, 0) + self.addPermanentWidget(self.docIcon) + self.addPermanentWidget(self.docText) + + ## The Project Status + self.projIcon = StatusLED(colNone, colTrue, colFalse, 14, 14, self) + self.projText = QLabel("Project") + self.projIcon.setContentsMargins(0, 0, 0, 0) + self.projText.setContentsMargins(0, 0, 8, 0) + self.addPermanentWidget(self.projIcon) + self.addPermanentWidget(self.projText) + + ## The Project and Session Stats + self.statsIcon = QLabel() + self.statsText = QLabel("") + self.statsIcon.setPixmap(self.theParent.theTheme.getPixmap("status_stats",(14,14))) + self.statsIcon.setContentsMargins(0, 0, 0, 0) + self.statsText.setContentsMargins(0, 0, 8, 0) + self.addPermanentWidget(self.statsIcon) + self.addPermanentWidget(self.statsText) + + ## The Session Clock + self.timeIcon = QLabel() + self.timeText = QLabel("") + self.timeIcon.setPixmap(self.theParent.theTheme.getPixmap("status_time",(14,14))) + self.timeText.setToolTip("Session Time") + self.timeText.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + self.timeText.setFont(self.monoFont) + self.timeIcon.setContentsMargins(0, 0, 0, 0) + self.timeText.setContentsMargins(0, 0, 0, 0) + self.addPermanentWidget(self.timeIcon) + self.addPermanentWidget(self.timeText) + + # Other Settings self.setSizeGripEnabled(True) + # Start the Clock self.sessionTimer = QTimer() self.sessionTimer.setInterval(1000) self.sessionTimer.timeout.connect(self._updateTime) @@ -114,64 +127,102 @@ class GuiMainStatus(QStatusBar): return def clearStatus(self): + """Reset all widgets on the status bar to default values. + """ self.setRefTime(None) - self.setStats(0,0) - self.setCounts(0,0,0) + self.setStats(0, 0) + self.setCounts(0, 0, 0) self.setProjectStatus(None) self.setDocumentStatus(None) self._updateTime() return True def setRefTime(self, theTime): + """Set the reference time for the status bar clock. + """ self.refTime = theTime return def setStatus(self, theMessage, timeOut=10.0): + """Set the status bar message to display for 'timeOut' seconds. + """ self.showMessage(theMessage, int(timeOut*1000)) + qApp.processEvents() return def setLanguage(self, theLanguage): + """Set the language code for the spell checker. + """ if theLanguage is None: - self.langBox.setText("None") + self.langText.setText("None") else: - self.langBox.setText(NWSpellCheck.expandLanguage(theLanguage)) + self.langText.setText(NWSpellCheck.expandLanguage(theLanguage)) return def setProjectStatus(self, isChanged): - if isChanged is None: - self.projChanged.setPixmap(self.iconGrey) - elif isChanged == True: - self.projChanged.setPixmap(self.iconYellow) - elif isChanged == False: - self.projChanged.setPixmap(self.iconGreen) - else: - self.projChanged.setPixmap(self.iconGrey) + """Set the project status colour icon. + """ + self.projIcon.setState(isChanged) return def setDocumentStatus(self, isChanged): - if isChanged is None: - self.docChanged.setPixmap(self.iconGrey) - elif isChanged == True: - self.docChanged.setPixmap(self.iconYellow) - elif isChanged == False: - self.docChanged.setPixmap(self.iconGreen) - else: - self.docChanged.setPixmap(self.iconGrey) + """Set the document status colour icon. + """ + self.docIcon.setState(isChanged) return def setStats(self, pWC, sWC): - self.boxStats.setText("Project: {:d} : {:d}".format(pWC,sWC)) + """Set the current project statistics. + """ + self.projWords = pWC + self.sessWords = sWC + self._updateStats() return def setCounts(self, cC, wC, pC): - self.boxCounts.setText("Document: {:d} : {:d} : {:d}".format(cC,wC,pC)) + """Set the current document statistics. + """ + self.charCount = cC + self.wordCount = wC + self.paraCount = pC + self._updateStats() return ## # Internal Functions ## + def _updateStats(self): + """Update statistics. + """ + self.statsText.setToolTip(( + "Document Stats
" + "Characters: {cC:n}
" + "Words: {wC:n}
" + "Paragraphs: {pC:n}
" + "
" + "Project Stats
" + "Words Total: {pWC:n}
" + "This Session: {sWC:n}" + ).format( + cC = self.charCount, + wC = self.wordCount, + pC = self.paraCount, + pWC = self.projWords, + sWC = self.sessWords, + )) + self.statsText.setText(( + "D:{wC:n} P:{pWC:n} S:{sWC:n}" + ).format( + wC = self.wordCount, + pWC = self.projWords, + sWC = self.sessWords, + )) + return + def _updateTime(self): + """Update the session clock. + """ if self.refTime is None: theTime = "00:00:00" else: @@ -182,7 +233,58 @@ class GuiMainStatus(QStatusBar): tM = tM - tH*60 tS = tS - tM*60 - tH*3600 theTime = "%02d:%02d:%02d" % (tH,tM,tS) - self.timeBox.setText(theTime) + self.timeText.setText(theTime) return # END Class GuiMainStatus + +class StatusLED(QAbstractButton): + + def __init__(self, colNone, colTrue, colFalse, sW, sH, parent=None): + super().__init__(parent=parent) + + self.colNone = colNone + self.colTrue = colTrue + self.colFalse = colFalse + self._theCol = colNone + + self.setFixedWidth(sW) + self.setFixedHeight(sH) + + return + + ## + # Getters and Setters + ## + + def setState(self, theState): + """Set the colour state. + """ + if theState is None: + self._theCol = self.colNone + elif theState == True: + self._theCol = self.colTrue + elif theState == False: + self._theCol = self.colFalse + else: + self._theCol = self.colNone + self.update() + return + + ## + # Events + ## + + def paintEvent(self, event): + """Drawing the LED. + """ + qPalette = self.palette() + qPaint = QPainter(self) + qPaint.setRenderHint(QPainter.Antialiasing, True) + qPaint.setPen(qPalette.dark().color()) + qPaint.setBrush(self._theCol) + qPaint.setOpacity(1.0) + qPaint.drawEllipse(1, 1, self.width()-2, self.height()-2) + return + +# END Class StatusLED diff --git a/nw/gui/tools/dochighlight.py b/nw/gui/tools/dochighlight.py index fbe3a02e..5ac14af5 100644 --- a/nw/gui/tools/dochighlight.py +++ b/nw/gui/tools/dochighlight.py @@ -91,7 +91,8 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.colTagErr = QColor(*self.theTheme.colTagErr) self.colRepTag = QColor(*self.theTheme.colRepTag) self.colMod = QColor(*self.theTheme.colMod) - self.colTrail = QColor(*self.theTheme.colEmph,64) + self.colTrail = QColor(*self.theTheme.colEmph) + self.colTrail.setAlpha(64) self.hStyles = { "header1" : self._makeFormat(self.colHead, "bold",1.8), diff --git a/nw/guimain.py b/nw/guimain.py index c3c00fd6..e8c0840a 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -26,17 +26,17 @@ """ import logging -import time import nw from os import path from datetime import datetime +from time import time from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence +from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence, QCursor from PyQt5.QtWidgets import ( qApp, QMainWindow, QVBoxLayout, QFrame, QSplitter, QFileDialog, QShortcut, - QMessageBox, QProgressDialog, QDialog, QTabWidget + QMessageBox, QDialog, QTabWidget ) from nw.gui import ( @@ -638,6 +638,8 @@ class GuiMain(QMainWindow): return def rebuildTree(self): + """Rebuild the project tree. + """ self._makeStatusIcons() self._makeImportIcons() self.treeView.clearTree() @@ -645,37 +647,25 @@ class GuiMain(QMainWindow): return def rebuildIndex(self): + """Rebuild the entire index. + """ if not self.hasProject: return False - logger.debug("Rebuilding indices ...") + logger.debug("Rebuilding index ...") + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + tStart = time() self.treeView.saveTreeOrder() self.theIndex.clearIndex() nItems = len(self.theProject.projTree) - dlgProg = QProgressDialog("Scanning files ...", "Cancel", 0, nItems, self) - dlgProg.setWindowModality(Qt.WindowModal) - dlgProg.setMinimumDuration(0) - dlgProg.setFixedWidth(480) - dlgProg.setLabelText("Starting file scan ...") - dlgProg.setValue(0) - dlgProg.show() - time.sleep(0.5) - - nDone = 0 - for tItem in self.theProject.projTree: - - dlgProg.setValue(nDone) - + theDoc = NWDoc(self.theProject, self) + for nDone, tItem in enumerate(self.theProject.projTree): if tItem is not None and tItem.itemType == nwItemType.FILE: - - dlgProg.setLabelText("Scanning: %s" % tItem.itemName) logger.verbose("Scanning: %s" % tItem.itemName) - - theDoc = NWDoc(self.theProject, self) - theText = theDoc.openDocument(tItem.itemHandle, False) + theText = theDoc.openDocument(tItem.itemHandle, showStatus=False) # Build tag index self.theIndex.scanText(tItem.itemHandle, theText) @@ -688,11 +678,16 @@ class GuiMain(QMainWindow): self.treeView.propagateCount(tItem.itemHandle, wC) self.treeView.projectWordCount() - nDone += 1 - if dlgProg.wasCanceled(): - break + self.statusBar.setStatus("Building index: %.2f%%" % (100.0*(nDone + 1)/nItems)) - dlgProg.setValue(nItems) + self.docEditor.reloadText() + qApp.restoreOverrideCursor() + tEnd = time() + + if self.mainConf.showGUI: + self.makeAlert( + "Project index rebuilt in %.3f seconds." % (tEnd - tStart), nwAlert.INFO + ) return True