diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index e86776ac..fc59f77f 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, Iterator from pathlib import Path from functools import partial -from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal +from PyQt5.QtCore import QCoreApplication from novelwriter import CONFIG, SHARED, __version__, __hexversion__ from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout @@ -55,13 +55,9 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) -class NWProject(QObject): +class NWProject: - statusChanged = pyqtSignal(bool) - statusMessage = pyqtSignal(str) - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent=parent) + def __init__(self) -> None: # Core Elements self._options = OptionState(self) # Project-specific GUI options @@ -206,7 +202,7 @@ class NWProject(QObject): def trashFolder(self) -> str: """Add the special trash root folder to the project.""" - trashHandle = self._tree.trashRoot() + trashHandle = self._tree.trashRoot if trashHandle is None: label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH]) return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH) @@ -331,7 +327,7 @@ class NWProject(QObject): self.setProjectChanged(False) self._valid = True - self.statusMessage.emit(self.tr("Opened Project: {0}").format(self._data.name)) + SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name)) return True @@ -381,7 +377,7 @@ class NWProject(QObject): ) self._storage.writeLockFile() - self.statusMessage.emit(self.tr("Saved Project: {0}").format(self._data.name)) + SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name)) self.setProjectChanged(False) return True @@ -403,7 +399,7 @@ class NWProject(QObject): return False logger.info("Backing up project") - self.statusMessage.emit(self.tr("Backing up project ...")) + SHARED.newStatusMessage(self.tr("Backing up project ...")) if not self._data.name: SHARED.error(self.tr( @@ -434,7 +430,7 @@ class NWProject(QObject): SHARED.error(self.tr("Could not write backup archive.")) return False - self.statusMessage.emit(self.tr("Project backed up to '{0}'").format(str(archName))) + SHARED.newStatusMessage(self.tr("Project backed up to '{0}'").format(str(archName))) return True @@ -488,7 +484,7 @@ class NWProject(QObject): """ if isinstance(status, bool): self._changed = status - self.statusChanged.emit(self._changed) + SHARED.setGlobalProjectState(self._changed) return self._changed ## diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index fef2759d..b0a79822 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -40,6 +40,8 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) +MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit) + class NWTree: """Core: Project Tree Data Class @@ -59,7 +61,7 @@ class NWTree: also used for file names. """ - MAX_DEPTH = 1000 # Cap of tree traversing for loops + __slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed") def __init__(self, project: NWProject) -> None: @@ -74,6 +76,15 @@ class NWTree: return + ## + # Properties + ## + + @property + def trashRoot(self) -> str | None: + """Return the handle of the trash folder, or None.""" + return self._trash + ## # Class Methods ## @@ -320,7 +331,7 @@ class NWTree: return False iItem = tItem - for _ in range(self.MAX_DEPTH): + for _ in range(MAX_DEPTH): if iItem.itemParent is None: tItem.setRoot(iItem.itemHandle) tItem.setClassDefaults(iItem.itemClass) @@ -349,7 +360,7 @@ class NWTree: tItem = self.__getitem__(tHandle) if tItem is not None: tTree.append(tHandle) - for _ in range(self.MAX_DEPTH): + for _ in range(MAX_DEPTH): if tItem.itemParent is None: return tTree else: @@ -400,14 +411,6 @@ class NWTree: return True return False - def trashRoot(self) -> str | None: - """Returns the handle of the trash folder, or None if there - isn't one. - """ - if self._trash: - return self._trash - return None - def findRoot(self, itemClass: nwItemClass | None) -> str | None: """Find the first root item for a given class.""" for aRoot in self._roots: diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 1905bdec..e753a828 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -798,7 +798,7 @@ class GuiProjectTree(QTreeWidget): logger.error("There is no item to delete") return False - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot if tHandle == trashHandle: logger.error("Cannot delete the Trash folder") return False @@ -823,7 +823,7 @@ class GuiProjectTree(QTreeWidget): logger.error("No project open") return False - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot logger.debug("Emptying Trash folder") if trashHandle is None: @@ -1201,7 +1201,7 @@ class GuiProjectTree(QTreeWidget): # Trash Folder # ============ - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot if tItem.itemHandle == trashHandle and trashHandle is not None: # The trash folder only has one option aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash")) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index d606b8d3..fbb16c0b 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -590,8 +590,8 @@ class GuiMain(QMainWindow): return True def openNextDocument(self, tHandle: str, wrapAround: bool = False) -> bool: - """Opens the next document in the project tree, following the - document with the given handle. Stops when reaching the end. + """Open the next document in the project tree, following the + document with the given handle. Stop when reaching the end. """ if not SHARED.hasProject: logger.error("No project open") diff --git a/novelwriter/shared.py b/novelwriter/shared.py index eea5fe26..262d5727 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -29,7 +29,7 @@ from time import time from typing import TYPE_CHECKING from pathlib import Path -from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtWidgets import QMessageBox, QWidget if TYPE_CHECKING: # pragma: no cover @@ -84,7 +84,7 @@ class SharedData(QObject): @property def hasProject(self) -> bool: - """Return True of the project instance is populated.""" + """Return True if the project instance is populated.""" return self.project.isValid @property @@ -107,9 +107,9 @@ class SharedData(QObject): ## def initSharedData(self, gui: GuiMain, theme: GuiTheme) -> None: - """Initialise the UserData instance. This must be called as soon - as the Main GUI is created to ensure the SHARED singleton has the - properties needed for operation. + """Initialise the SharedData instance. This must be called as + soon as the Main GUI is created to ensure the SHARED singleton + has the properties needed for operation. """ self._gui = gui self._theme = theme @@ -159,6 +159,20 @@ class SharedData(QObject): self._idleRefTime = currTime 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. + """ + self.projectStatusMessage.emit(message) + return + + def setGlobalProjectState(self, state: bool) -> None: + """Change the global project status. This is a callable function + for core classes that cannot emit signals on their own. + """ + self.projectStatusChanged.emit(state) + return + ## # Alert Boxes ## @@ -201,22 +215,6 @@ class SharedData(QObject): self._alert.exec_() return self._alert.result() == QMessageBox.Yes - ## - # Internal Slots - ## - - @pyqtSlot(bool) - def _emitProjectStatusChange(self, state: bool) -> None: - """Forward the project status slot.""" - self.projectStatusChanged.emit(state) - return - - @pyqtSlot(str) - def _emitProjectStatusMeesage(self, message: str) -> None: - """Forward the project message slot.""" - self.projectStatusMessage.emit(message) - return - ## # Internal Functions ## @@ -225,12 +223,8 @@ class SharedData(QObject): """Create a new project instance.""" from novelwriter.core.project import NWProject if isinstance(self._project, NWProject): - self._project.statusChanged.disconnect() - self._project.statusMessage.disconnect() - self._project.deleteLater() - self._project = NWProject(self) - self._project.statusChanged.connect(self._emitProjectStatusChange) - self._project.statusMessage.connect(self._emitProjectStatusMeesage) + del self._project + self._project = NWProject() return def _resetIdleTimer(self) -> None: diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 83abd8cb..0b36897b 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -119,7 +119,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert bool(theTree) is False # Check for archive and trash folders - assert theTree.trashRoot() is None + assert theTree.trashRoot is None aHandles = [] for nwItem in mockItems: @@ -146,7 +146,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): # ============ # Check that we have the correct archive and trash folders - assert theTree.trashRoot() == "a000000000003" + assert theTree.trashRoot == "a000000000003" assert theTree.findRoot(nwItemClass.ARCHIVE) == "a000000000002" assert theTree.isTrash("a000000000003") is True @@ -261,7 +261,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): del theTree["a000000000003"] assert len(theTree) == len(mockItems) - 3 assert "a000000000003" not in theTree - assert theTree.trashRoot() is None + assert theTree.trashRoot is None # END Test testCoreTree_BuildTree @@ -365,7 +365,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc @pytest.mark.core -def testCoreTree_Methods(mockGUI, mockItems): +def testCoreTree_Methods(monkeypatch, mockGUI, mockItems): """Test various class methods.""" theProject = NWProject() theTree = NWTree(theProject) @@ -389,11 +389,10 @@ def testCoreTree_Methods(mockGUI, mockItems): assert theTree.updateItemData("b000000000001") is True # Update item data, root is unreachable - maxDepth = theTree.MAX_DEPTH - theTree.MAX_DEPTH = 0 # type: ignore - with pytest.raises(RecursionError): - theTree.updateItemData("b000000000001") - theTree.MAX_DEPTH = maxDepth + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0) + with pytest.raises(RecursionError): + theTree.updateItemData("b000000000001") # Check type assert theTree.checkType("blabla", nwItemType.FILE) is False @@ -424,11 +423,10 @@ def testCoreTree_Methods(mockGUI, mockItems): ] # Cause recursion error - maxDepth = theTree.MAX_DEPTH - theTree.MAX_DEPTH = 0 # type: ignore - with pytest.raises(RecursionError): - theTree.getItemPath("c000000000001") - theTree.MAX_DEPTH = maxDepth + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0) + with pytest.raises(RecursionError): + theTree.getItemPath("c000000000001") # Break the folder parent handle theTree["b000000000001"]._parent = "stuff" # type: ignore diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index fd906971..0c65cb5a 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -205,7 +205,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert len(SHARED.project.tree) == 0 assert len(SHARED.project.tree._order) == 0 assert len(SHARED.project.tree._roots) == 0 - assert SHARED.project.tree.trashRoot() is None + assert SHARED.project.tree.trashRoot is None assert SHARED.project.data.name == "" assert SHARED.project.data.title == "" assert SHARED.project.data.author == "" @@ -225,7 +225,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert len(SHARED.project.tree) == 8 assert len(SHARED.project.tree._order) == 8 assert len(SHARED.project.tree._roots) == 4 - assert SHARED.project.tree.trashRoot() is None + assert SHARED.project.tree.trashRoot is None assert SHARED.project.data.name == "New Project" assert SHARED.project.data.title == "New Novel" assert SHARED.project.data.author == "Jane Doe" diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index bfb57a39..71b35df8 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -348,7 +348,7 @@ def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPat C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010" ] - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot assert projTree.getTreeFromHandle(trashHandle) == [ trashHandle, "0000000000012", "0000000000011" ] @@ -541,7 +541,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): projTree.setExpandedFromHandle(None, True) projTree._addTrashRoot() - hTrashRoot = SHARED.project.tree.trashRoot() + hTrashRoot = SHARED.project.tree.trashRoot projTree.setSelectedHandle(C.hCharRoot) projTree.newTreeItem(nwItemType.FILE)