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
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
@@ -320,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]:
+3
View File
@@ -147,14 +147,17 @@ class Index:
def rebuild(self) -> None:
"""Rebuild the entire index from scratch."""
self.clear()
SHARED.initMainProgress(len(self._project.tree))
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:
+20
View File
@@ -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
+16 -9
View File
@@ -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)
+20
View File
@@ -269,6 +269,26 @@ class SharedData(QObject):
self._idleRefTime = currTime
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:
"""Request a new status message. This is a callable function for
core classes that cannot emit signals on their own.
+1
View File
@@ -33,6 +33,7 @@ class MockGuiMain(QWidget):
self.mainStatus = MagicMock()
self.docEditor = MagicMock()
self.docViewer = MagicMock()
self.mainProgress = MagicMock()
self.projPath = ""
return
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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