From eb659a64bbb5520fad33ed47e1e6902edafcd9d5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:55:53 +0200 Subject: [PATCH 1/6] Add a progress bar over the status bar --- novelwriter/guimain.py | 25 ++++++++++++++++--------- novelwriter/shared.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 680f35d5..0be60dd3 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -45,6 +45,7 @@ from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.projectsettings import GuiProjectSettings from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwItemType, nwView +from novelwriter.extensions.progressbars import NProgressSimple from novelwriter.gui.doceditor import GuiDocEditor from novelwriter.gui.docviewer import GuiDocViewer from novelwriter.gui.docviewerpanel import GuiDocViewerPanel @@ -69,14 +70,10 @@ logger = logging.getLogger(__name__) class GuiMain(QMainWindow): """Main GUI Window - The Main GUI window class. It is the entry point of the - application, and holds all runtime objects aside from the main - Config instance, which is created before the Main GUI. - - The Main GUI is split up into GUI components, assembled in the init - function. Also, the project instance and theme instance are created - here. These should be passed around to all other objects who need - them and new instances of them should generally not be created. + The Main GUI window class is the entry point of the application. It + is split up into GUI components, assembled in the init function. + Tools and dialog windows are created on demand, but may be cached by + the Qt library and reused unless explicitly freed after use. """ def __init__(self) -> None: @@ -185,6 +182,10 @@ class GuiMain(QMainWindow): self.splitView.setVisible(False) self.docEditor.closeSearch() + # Progress Bar + self.mainProgress = NProgressSimple(self) + self.mainProgress.setFixedHeight(2) + # Assemble Main Window Elements self.mainBox = QHBoxLayout() self.mainBox.addWidget(self.sideBar) @@ -192,8 +193,14 @@ class GuiMain(QMainWindow): self.mainBox.setContentsMargins(0, 0, 0, 0) self.mainBox.setSpacing(0) + self.outerBox = QVBoxLayout() + self.outerBox.addLayout(self.mainBox) + self.outerBox.addWidget(self.mainProgress) + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox.setSpacing(0) + self.mainWidget = QWidget(self) - self.mainWidget.setLayout(self.mainBox) + self.mainWidget.setLayout(self.outerBox) self.setMenuBar(self.mainMenu) self.setCentralWidget(self.mainWidget) diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 7a276d02..6e184ee5 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -269,6 +269,25 @@ class SharedData(QObject): self._idleRefTime = currTime return + def startMainProgress(self, maximum: int) -> None: + """Start a session for the main progress bar.""" + if gui := self._gui: + gui.mainProgress.setMaximum(maximum) + return + + def updateMainProgress(self, value: int) -> None: + """Update the value for the main progress bar.""" + if gui := self._gui: + gui.mainProgress.setValue(value) + return + + def clearMainProgress(self) -> None: + """Clear the main progress bar.""" + if gui := self._gui: + gui.mainProgress.setMaximum(100) + gui.mainProgress.setValue(0) + return + def newStatusMessage(self, message: str) -> None: """Request a new status message. This is a callable function for core classes that cannot emit signals on their own. From d027f218f882f760eca1228c4a5ac40fe4c766fe Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:56:28 +0200 Subject: [PATCH 2/6] Add progress bar functions in shared class and use it for index rebuild --- novelwriter/core/index.py | 3 +++ novelwriter/shared.py | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 07933690..0165444f 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -147,14 +147,17 @@ class Index: def rebuild(self) -> None: """Rebuild the entire index from scratch.""" self.clear() + SHARED.initMainProgress(len(self._project.tree) - 1) for nwItem in self._project.tree: if nwItem.isFileType(): text = self._project.storage.getDocumentText(nwItem.itemHandle) self.scanText(nwItem.itemHandle, text, blockSignal=True) + SHARED.incMainProgress() self._indexBroken = False SHARED.emitIndexAvailable(self._project) for tHandle in self._novelModels: self.refreshNovelModel(tHandle) + SHARED.clearMainProgress() return def deleteHandle(self, tHandle: str) -> None: diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 6e184ee5..2a2eb277 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -269,10 +269,11 @@ class SharedData(QObject): self._idleRefTime = currTime return - def startMainProgress(self, maximum: int) -> None: + def initMainProgress(self, maximum: int) -> None: """Start a session for the main progress bar.""" if gui := self._gui: gui.mainProgress.setMaximum(maximum) + gui.mainProgress.setValue(0) return def updateMainProgress(self, value: int) -> None: @@ -281,11 +282,16 @@ class SharedData(QObject): gui.mainProgress.setValue(value) return - def clearMainProgress(self) -> None: + def incMainProgress(self) -> None: + """Increment the value for the main progress bar.""" + if gui := self._gui: + gui.mainProgress.setValue(gui.mainProgress.value() + 1) + return + + def clearMainProgress(self, delay: float = 1.0) -> None: """Clear the main progress bar.""" if gui := self._gui: - gui.mainProgress.setMaximum(100) - gui.mainProgress.setValue(0) + QTimer.singleShot(int(delay*1000), gui.mainProgress.reset) return def newStatusMessage(self, message: str) -> None: From 1223e6da847f204d30c71ba3b49d85b26cd8bc30 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:30:07 +0200 Subject: [PATCH 3/6] Fix tests and add GUI refresh --- novelwriter/shared.py | 2 ++ tests/mocked.py | 1 + 2 files changed, 3 insertions(+) diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 2a2eb277..f213db16 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -280,12 +280,14 @@ class SharedData(QObject): """Update the value for the main progress bar.""" if gui := self._gui: gui.mainProgress.setValue(value) + QApplication.processEvents() return def incMainProgress(self) -> None: """Increment the value for the main progress bar.""" if gui := self._gui: gui.mainProgress.setValue(gui.mainProgress.value() + 1) + QApplication.processEvents() return def clearMainProgress(self, delay: float = 1.0) -> None: diff --git a/tests/mocked.py b/tests/mocked.py index e1e4097c..37cad04c 100644 --- a/tests/mocked.py +++ b/tests/mocked.py @@ -33,6 +33,7 @@ class MockGuiMain(QWidget): self.mainStatus = MagicMock() self.docEditor = MagicMock() self.docViewer = MagicMock() + self.mainProgress = MagicMock() self.projPath = "" return From e77b71bbbc3be9d4a82fb4ddbb905dd1bfbf82f7 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 17 Jul 2025 18:06:02 +0200 Subject: [PATCH 4/6] Connect main progress bar to potential bulky operations in the project tree --- novelwriter/core/coretools.py | 7 +++++++ novelwriter/core/index.py | 2 +- novelwriter/gui/projtree.py | 20 ++++++++++++++++++++ novelwriter/shared.py | 11 ++--------- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index bb68a810..31b1e084 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -160,6 +160,10 @@ class DocSplitter: return + def __len__(self) -> int: + """The length of the split job.""" + return len(self._rawData) + ## # Methods ## @@ -270,7 +274,9 @@ class DocDuplicator: after = True if items: hMap: dict[str, str | None] = {t: None for t in items} + SHARED.initMainProgress(len(items)) for tHandle in items: + SHARED.incMainProgress() if oldItem := self._project.tree[tHandle]: pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent if newItem := self._project.tree.duplicate(tHandle, pHandle, after): @@ -282,6 +288,7 @@ class DocDuplicator: after = False else: break + SHARED.clearMainProgress() return result diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 0165444f..a0c11ff1 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -147,7 +147,7 @@ class Index: def rebuild(self) -> None: """Rebuild the entire index from scratch.""" self.clear() - SHARED.initMainProgress(len(self._project.tree) - 1) + SHARED.initMainProgress(len(self._project.tree)) for nwItem in self._project.tree: if nwItem.isFileType(): text = self._project.storage.getDocumentText(nwItem.itemHandle) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index b35445ae..87a30855 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -718,9 +718,15 @@ class GuiProjectTree(QTreeView): else: return False + SHARED.initMainProgress(len(items)) + self.setEnabled(False) for sHandle in items: + SHARED.incMainProgress() docMerger.appendText(sHandle, True, mLabel) + self.setEnabled(True) + SHARED.clearMainProgress() + if not docMerger.writeTargetDoc(): SHARED.error( self.tr("Could not write document content."), @@ -764,7 +770,10 @@ class GuiProjectTree(QTreeView): docSplit.setParentItem(tItem.itemParent) docSplit.splitDocument(headerList, text) + SHARED.initMainProgress(len(docSplit)) + self.setEnabled(False) for writeOk in docSplit.writeDocuments(docHierarchy): + SHARED.incMainProgress() if not writeOk: SHARED.error( self.tr("Could not write document content."), @@ -774,6 +783,9 @@ class GuiProjectTree(QTreeView): if data.get("moveToTrash", False): self.processDeleteRequest([tHandle], False) + self.setEnabled(True) + SHARED.clearMainProgress() + return True def duplicateFromHandle(self, tHandle: str) -> None: @@ -786,10 +798,12 @@ class GuiProjectTree(QTreeView): else: question = self.tr("Do you want to duplicate this item and all child items?") if SHARED.question(question): + self.setEnabled(False) docDup = DocDuplicator(SHARED.project) dHandles = docDup.duplicate(itemTree) if len(dHandles) != len(itemTree): SHARED.warn(self.tr("Could not duplicate all items.")) + self.setEnabled(True) self.restoreExpandedState() return @@ -912,17 +926,23 @@ class GuiProjectTree(QTreeView): if not SHARED.question(self.tr("Permanently delete selected item(s)?")): logger.info("Action cancelled by user") return + + self.setEnabled(False) for index in indices: if node := model.node(index): for child in reversed(node.allChildren()): SHARED.project.removeItem(child.item.itemHandle) SHARED.project.removeItem(node.item.itemHandle) + self.setEnabled(True) elif trashNode := SHARED.project.tree.trash: if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")): logger.info("Action cancelled by user") return + + self.setEnabled(False) model.multiMove(indices, model.indexFromNode(trashNode)) + self.setEnabled(True) return diff --git a/novelwriter/shared.py b/novelwriter/shared.py index f213db16..28c8d5be 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -269,20 +269,13 @@ class SharedData(QObject): self._idleRefTime = currTime return - def initMainProgress(self, maximum: int) -> None: + def initMainProgress(self, maximum: int, inclusive: bool = False) -> None: """Start a session for the main progress bar.""" if gui := self._gui: - gui.mainProgress.setMaximum(maximum) + gui.mainProgress.setMaximum(maximum - (1 if inclusive else 0)) gui.mainProgress.setValue(0) return - def updateMainProgress(self, value: int) -> None: - """Update the value for the main progress bar.""" - if gui := self._gui: - gui.mainProgress.setValue(value) - QApplication.processEvents() - return - def incMainProgress(self) -> None: """Increment the value for the main progress bar.""" if gui := self._gui: From 4bc4c1e2044e6f9874baa3f21def1c43e206e58c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:42:35 +0200 Subject: [PATCH 5/6] Add progress bar to global search --- novelwriter/core/coretools.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 31b1e084..a46a9e72 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -327,10 +327,13 @@ class DocSearch: self._regEx = re.compile(self._buildPattern(search), self._opts) logger.debug("Searching with pattern '%s'", self._regEx.pattern) storage = project.storage + SHARED.initMainProgress(len(project.tree)) for item in project.tree: + SHARED.incMainProgress() if item.isFileType(): results, capped = self.searchText(storage.getDocumentText(item.itemHandle)) yield item, results, capped + SHARED.clearMainProgress() return def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]: From f7366cb2ff279d83cbf1e27061e7f20d31730a6a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:47:46 +0200 Subject: [PATCH 6/6] Fix linting errors for new rules --- tests/test_gui/test_gui_guimain.py | 8 ++++---- tests/test_gui/test_gui_theme.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 3940cbba..1ebfa42c 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -827,13 +827,13 @@ def testGuiMain_OpenClose(qtbot, monkeypatch, nwGUI, projPath, fncPath, mockRnd) # Handle broken index on project open nwGUI.closeProject() idxPath: Path = projPath / "meta" / nwFiles.INDEX_FILE - assert idxPath.read_text() != "{}" - idxPath.write_text("{}") - assert idxPath.read_text() == "{}" + assert idxPath.read_text(encoding="utf-8") != "{}" + idxPath.write_text("{}", encoding="utf-8") + assert idxPath.read_text(encoding="utf-8") == "{}" nwGUI.openProject(projPath) nwGUI.saveProject() - assert idxPath.read_text() != "{}" + assert idxPath.read_text(encoding="utf-8") != "{}" assert nwGUI.docEditor.docHandle == C.hSceneDoc assert nwGUI.docViewer.docHandle == C.hTitlePage diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index 49c6c92c..68b75fd4 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -213,7 +213,7 @@ def testGuiTheme_SpecialColors(tstPaths): theme.iconCache = MagicMock() testTheme: Path = tstPaths.cnfDir / "themes" / "test.conf" - testTheme.write_text( + testTheme.write_text(( "[Main]\n" "name = Test\n" "mode = light\n" @@ -241,7 +241,7 @@ def testGuiTheme_SpecialColors(tstPaths): "[Palette]\n" "window = #000000\n" "text = #ffffff\n" - ) + ), encoding="utf-8") theme._scanThemes([testTheme]) assert len(theme.colourThemes) == 1 CONFIG.themeMode = nwTheme.LIGHT