Add progress bar for bulky tasks (#2478)

This commit is contained in:
Veronica Berglyd Olsen
2025-07-17 19:51:36 +02:00
committed by GitHub
8 changed files with 76 additions and 15 deletions
+10
View File
@@ -160,6 +160,10 @@ class DocSplitter:
return return
def __len__(self) -> int:
"""The length of the split job."""
return len(self._rawData)
## ##
# Methods # Methods
## ##
@@ -270,7 +274,9 @@ class DocDuplicator:
after = True after = True
if items: if items:
hMap: dict[str, str | None] = {t: None for t in items} hMap: dict[str, str | None] = {t: None for t in items}
SHARED.initMainProgress(len(items))
for tHandle in items: for tHandle in items:
SHARED.incMainProgress()
if oldItem := self._project.tree[tHandle]: if oldItem := self._project.tree[tHandle]:
pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
if newItem := self._project.tree.duplicate(tHandle, pHandle, after): if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
@@ -282,6 +288,7 @@ class DocDuplicator:
after = False after = False
else: else:
break break
SHARED.clearMainProgress()
return result return result
@@ -320,10 +327,13 @@ class DocSearch:
self._regEx = re.compile(self._buildPattern(search), self._opts) self._regEx = re.compile(self._buildPattern(search), self._opts)
logger.debug("Searching with pattern '%s'", self._regEx.pattern) logger.debug("Searching with pattern '%s'", self._regEx.pattern)
storage = project.storage storage = project.storage
SHARED.initMainProgress(len(project.tree))
for item in project.tree: for item in project.tree:
SHARED.incMainProgress()
if item.isFileType(): if item.isFileType():
results, capped = self.searchText(storage.getDocumentText(item.itemHandle)) results, capped = self.searchText(storage.getDocumentText(item.itemHandle))
yield item, results, capped yield item, results, capped
SHARED.clearMainProgress()
return return
def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]: def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]:
+3
View File
@@ -147,14 +147,17 @@ class Index:
def rebuild(self) -> None: def rebuild(self) -> None:
"""Rebuild the entire index from scratch.""" """Rebuild the entire index from scratch."""
self.clear() self.clear()
SHARED.initMainProgress(len(self._project.tree))
for nwItem in self._project.tree: for nwItem in self._project.tree:
if nwItem.isFileType(): if nwItem.isFileType():
text = self._project.storage.getDocumentText(nwItem.itemHandle) text = self._project.storage.getDocumentText(nwItem.itemHandle)
self.scanText(nwItem.itemHandle, text, blockSignal=True) self.scanText(nwItem.itemHandle, text, blockSignal=True)
SHARED.incMainProgress()
self._indexBroken = False self._indexBroken = False
SHARED.emitIndexAvailable(self._project) SHARED.emitIndexAvailable(self._project)
for tHandle in self._novelModels: for tHandle in self._novelModels:
self.refreshNovelModel(tHandle) self.refreshNovelModel(tHandle)
SHARED.clearMainProgress()
return return
def deleteHandle(self, tHandle: str) -> None: def deleteHandle(self, tHandle: str) -> None:
+20
View File
@@ -718,9 +718,15 @@ class GuiProjectTree(QTreeView):
else: else:
return False return False
SHARED.initMainProgress(len(items))
self.setEnabled(False)
for sHandle in items: for sHandle in items:
SHARED.incMainProgress()
docMerger.appendText(sHandle, True, mLabel) docMerger.appendText(sHandle, True, mLabel)
self.setEnabled(True)
SHARED.clearMainProgress()
if not docMerger.writeTargetDoc(): if not docMerger.writeTargetDoc():
SHARED.error( SHARED.error(
self.tr("Could not write document content."), self.tr("Could not write document content."),
@@ -764,7 +770,10 @@ class GuiProjectTree(QTreeView):
docSplit.setParentItem(tItem.itemParent) docSplit.setParentItem(tItem.itemParent)
docSplit.splitDocument(headerList, text) docSplit.splitDocument(headerList, text)
SHARED.initMainProgress(len(docSplit))
self.setEnabled(False)
for writeOk in docSplit.writeDocuments(docHierarchy): for writeOk in docSplit.writeDocuments(docHierarchy):
SHARED.incMainProgress()
if not writeOk: if not writeOk:
SHARED.error( SHARED.error(
self.tr("Could not write document content."), self.tr("Could not write document content."),
@@ -774,6 +783,9 @@ class GuiProjectTree(QTreeView):
if data.get("moveToTrash", False): if data.get("moveToTrash", False):
self.processDeleteRequest([tHandle], False) self.processDeleteRequest([tHandle], False)
self.setEnabled(True)
SHARED.clearMainProgress()
return True return True
def duplicateFromHandle(self, tHandle: str) -> None: def duplicateFromHandle(self, tHandle: str) -> None:
@@ -786,10 +798,12 @@ class GuiProjectTree(QTreeView):
else: else:
question = self.tr("Do you want to duplicate this item and all child items?") question = self.tr("Do you want to duplicate this item and all child items?")
if SHARED.question(question): if SHARED.question(question):
self.setEnabled(False)
docDup = DocDuplicator(SHARED.project) docDup = DocDuplicator(SHARED.project)
dHandles = docDup.duplicate(itemTree) dHandles = docDup.duplicate(itemTree)
if len(dHandles) != len(itemTree): if len(dHandles) != len(itemTree):
SHARED.warn(self.tr("Could not duplicate all items.")) SHARED.warn(self.tr("Could not duplicate all items."))
self.setEnabled(True)
self.restoreExpandedState() self.restoreExpandedState()
return return
@@ -912,17 +926,23 @@ class GuiProjectTree(QTreeView):
if not SHARED.question(self.tr("Permanently delete selected item(s)?")): if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
logger.info("Action cancelled by user") logger.info("Action cancelled by user")
return return
self.setEnabled(False)
for index in indices: for index in indices:
if node := model.node(index): if node := model.node(index):
for child in reversed(node.allChildren()): for child in reversed(node.allChildren()):
SHARED.project.removeItem(child.item.itemHandle) SHARED.project.removeItem(child.item.itemHandle)
SHARED.project.removeItem(node.item.itemHandle) SHARED.project.removeItem(node.item.itemHandle)
self.setEnabled(True)
elif trashNode := SHARED.project.tree.trash: elif trashNode := SHARED.project.tree.trash:
if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")): if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
logger.info("Action cancelled by user") logger.info("Action cancelled by user")
return return
self.setEnabled(False)
model.multiMove(indices, model.indexFromNode(trashNode)) model.multiMove(indices, model.indexFromNode(trashNode))
self.setEnabled(True)
return return
+16 -9
View File
@@ -45,6 +45,7 @@ from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwItemType, nwView 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.doceditor import GuiDocEditor
from novelwriter.gui.docviewer import GuiDocViewer from novelwriter.gui.docviewer import GuiDocViewer
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
@@ -69,14 +70,10 @@ logger = logging.getLogger(__name__)
class GuiMain(QMainWindow): class GuiMain(QMainWindow):
"""Main GUI Window """Main GUI Window
The Main GUI window class. It is the entry point of the The Main GUI window class is the entry point of the application. It
application, and holds all runtime objects aside from the main is split up into GUI components, assembled in the init function.
Config instance, which is created before the Main GUI. Tools and dialog windows are created on demand, but may be cached by
the Qt library and reused unless explicitly freed after use.
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.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@@ -185,6 +182,10 @@ class GuiMain(QMainWindow):
self.splitView.setVisible(False) self.splitView.setVisible(False)
self.docEditor.closeSearch() self.docEditor.closeSearch()
# Progress Bar
self.mainProgress = NProgressSimple(self)
self.mainProgress.setFixedHeight(2)
# Assemble Main Window Elements # Assemble Main Window Elements
self.mainBox = QHBoxLayout() self.mainBox = QHBoxLayout()
self.mainBox.addWidget(self.sideBar) self.mainBox.addWidget(self.sideBar)
@@ -192,8 +193,14 @@ class GuiMain(QMainWindow):
self.mainBox.setContentsMargins(0, 0, 0, 0) self.mainBox.setContentsMargins(0, 0, 0, 0)
self.mainBox.setSpacing(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 = QWidget(self)
self.mainWidget.setLayout(self.mainBox) self.mainWidget.setLayout(self.outerBox)
self.setMenuBar(self.mainMenu) self.setMenuBar(self.mainMenu)
self.setCentralWidget(self.mainWidget) self.setCentralWidget(self.mainWidget)
+20
View File
@@ -269,6 +269,26 @@ class SharedData(QObject):
self._idleRefTime = currTime self._idleRefTime = currTime
return return
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 - (1 if inclusive else 0))
gui.mainProgress.setValue(0)
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:
"""Clear the main progress bar."""
if gui := self._gui:
QTimer.singleShot(int(delay*1000), gui.mainProgress.reset)
return
def newStatusMessage(self, message: str) -> None: def newStatusMessage(self, message: str) -> None:
"""Request a new status message. This is a callable function for """Request a new status message. This is a callable function for
core classes that cannot emit signals on their own. core classes that cannot emit signals on their own.
+1
View File
@@ -33,6 +33,7 @@ class MockGuiMain(QWidget):
self.mainStatus = MagicMock() self.mainStatus = MagicMock()
self.docEditor = MagicMock() self.docEditor = MagicMock()
self.docViewer = MagicMock() self.docViewer = MagicMock()
self.mainProgress = MagicMock()
self.projPath = "" self.projPath = ""
return return
+4 -4
View File
@@ -827,13 +827,13 @@ def testGuiMain_OpenClose(qtbot, monkeypatch, nwGUI, projPath, fncPath, mockRnd)
# Handle broken index on project open # Handle broken index on project open
nwGUI.closeProject() nwGUI.closeProject()
idxPath: Path = projPath / "meta" / nwFiles.INDEX_FILE idxPath: Path = projPath / "meta" / nwFiles.INDEX_FILE
assert idxPath.read_text() != "{}" assert idxPath.read_text(encoding="utf-8") != "{}"
idxPath.write_text("{}") idxPath.write_text("{}", encoding="utf-8")
assert idxPath.read_text() == "{}" assert idxPath.read_text(encoding="utf-8") == "{}"
nwGUI.openProject(projPath) nwGUI.openProject(projPath)
nwGUI.saveProject() nwGUI.saveProject()
assert idxPath.read_text() != "{}" assert idxPath.read_text(encoding="utf-8") != "{}"
assert nwGUI.docEditor.docHandle == C.hSceneDoc assert nwGUI.docEditor.docHandle == C.hSceneDoc
assert nwGUI.docViewer.docHandle == C.hTitlePage assert nwGUI.docViewer.docHandle == C.hTitlePage
+2 -2
View File
@@ -213,7 +213,7 @@ def testGuiTheme_SpecialColors(tstPaths):
theme.iconCache = MagicMock() theme.iconCache = MagicMock()
testTheme: Path = tstPaths.cnfDir / "themes" / "test.conf" testTheme: Path = tstPaths.cnfDir / "themes" / "test.conf"
testTheme.write_text( testTheme.write_text((
"[Main]\n" "[Main]\n"
"name = Test\n" "name = Test\n"
"mode = light\n" "mode = light\n"
@@ -241,7 +241,7 @@ def testGuiTheme_SpecialColors(tstPaths):
"[Palette]\n" "[Palette]\n"
"window = #000000\n" "window = #000000\n"
"text = #ffffff\n" "text = #ffffff\n"
) ), encoding="utf-8")
theme._scanThemes([testTheme]) theme._scanThemes([testTheme])
assert len(theme.colourThemes) == 1 assert len(theme.colourThemes) == 1
CONFIG.themeMode = nwTheme.LIGHT CONFIG.themeMode = nwTheme.LIGHT