Skip to content

Commit d51b7fd

Browse files
committed
refactor(architecture): extract ProjectManager class from mainWindow
Major architectural refactoring to improve code organization: - **Extracted ProjectManager class** from mainWindow.py (~300 lines) - Moved project lifecycle methods: loadProject, closeProject, saveDatas - Moved timer management: saveTimer, saveTimerNoChanges - Moved data management: loadEmptyDatas, loadDatas - Moved unsaved changes handling: handleUnsavedChanges - Updated all calling code to use projectManager interface - `manuskript/projectManager.py` - Project lifecycle management - `manuskript/tests/test_projectManager.py` - Basic ProjectManager tests (2 tests) - `manuskript/tests/test_projectManager_timer.py` - Comprehensive timer tests (9 tests) - `manuskript/mainWindow.py` - Removed ~300 lines, added projectManager integration - `manuskript/ui/welcome.py` - Updated to use projectManager interface - `manuskript/settingsWindow.py` - Updated timer access via projectManager - `manuskript/tests/conftest.py` - Updated test fixtures for new interface - Added 11 new unit tests total covering ProjectManager functionality - Comprehensive timer testing with proper Qt mocking - All existing tests continue to pass (50 total tests) - Tests validate both positive and negative scenarios - Reduced mainWindow.py complexity (2000+ -> 1700 lines) - Improved separation of concerns - Better testability with isolated components - Foundation for further architectural improvements
1 parent 7737395 commit d51b7fd

File tree

7 files changed

+477
-328
lines changed

7 files changed

+477
-328
lines changed

manuskript/mainWindow.py

Lines changed: 4 additions & 314 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from manuskript.models import outlineModel
2323
from manuskript.models.plotModel import plotModel
2424
from manuskript.models.worldModel import worldModel
25+
from manuskript.projectManager import ProjectManager
2526
from manuskript.settingsWindow import settingsWindow
2627
from manuskript.ui import style
2728
from manuskript.ui import characterInfoDialog
@@ -76,6 +77,7 @@ def __init__(self):
7677
self.sessionStartWordCount = 0 # Used to track session targets
7778
self.history = History()
7879
self._previousSelectionEmpty = True
80+
self.projectManager = ProjectManager(self)
7981

8082
self.readSettings()
8183

@@ -113,11 +115,11 @@ def __init__(self):
113115

114116
# Main Menu:: File
115117
self.actOpen.triggered.connect(self.welcome.openFile)
116-
self.actSave.triggered.connect(self.saveDatas)
118+
self.actSave.triggered.connect(self.projectManager.saveDatas)
117119
self.actSaveAs.triggered.connect(self.welcome.saveAsFile)
118120
self.actImport.triggered.connect(self.doImport)
119121
self.actCompile.triggered.connect(self.doCompile)
120-
self.actCloseProject.triggered.connect(self.closeProject)
122+
self.actCloseProject.triggered.connect(self.projectManager.closeProject)
121123
self.actQuit.triggered.connect(self.close)
122124

123125
# Main menu:: Edit
@@ -905,191 +907,6 @@ def navigated(self, event):
905907
self.actBack.setEnabled(event.position > 0)
906908
self.actForward.setEnabled(event.position < event.count - 1)
907909

908-
###############################################################################
909-
# LOAD AND SAVE
910-
###############################################################################
911-
912-
def loadProject(self, project, loadFromFile=True):
913-
"""Loads the project ``project``.
914-
915-
If ``loadFromFile`` is False, then it does not load datas from file.
916-
It assumes that the datas have been populated in a different way."""
917-
918-
# Convert project path to OS norm
919-
project = os.path.normpath(project)
920-
921-
if loadFromFile and not os.path.exists(project):
922-
LOGGER.warning("The file {} does not exist. Has it been moved or deleted?".format(project))
923-
F.statusMessage(
924-
self.tr("The file {} does not exist. Has it been moved or deleted?").format(project), importance=3)
925-
return
926-
927-
if loadFromFile:
928-
# Load empty settings
929-
importlib.reload(settings)
930-
settings.initDefaultValues()
931-
932-
# Load data
933-
self.loadEmptyDatas()
934-
935-
if not self.loadDatas(project):
936-
self.closeProject()
937-
return
938-
939-
self.makeConnections()
940-
941-
# Load settings
942-
if settings.openIndexes and settings.openIndexes != [""]:
943-
self.mainEditor.tabSplitter.restoreOpenIndexes(settings.openIndexes)
944-
self.generateViewMenu()
945-
self.mainEditor.sldCorkSizeFactor.setValue(settings.corkSizeFactor)
946-
self.actSpellcheck.setChecked(settings.spellcheck)
947-
self.toggleSpellcheck(settings.spellcheck)
948-
self.updateMenuDict()
949-
self.setDictionary()
950-
951-
iconSize = settings.viewSettings["Tree"]["iconSize"]
952-
self.treeRedacOutline.setIconSize(QSize(iconSize, iconSize))
953-
self.mainEditor.setFolderView(settings.folderView)
954-
self.mainEditor.updateFolderViewButtons(settings.folderView)
955-
self.mainEditor.tabSplitter.updateStyleSheet()
956-
self.tabMain.setCurrentIndex(settings.lastTab)
957-
self.mainEditor.updateCorkBackground()
958-
if settings.viewMode == "simple":
959-
self.setViewModeSimple()
960-
else:
961-
self.setViewModeFiction()
962-
963-
# Set autosave
964-
self.saveTimer = QTimer()
965-
self.saveTimer.setInterval(settings.autoSaveDelay * 60 * 1000)
966-
self.saveTimer.setSingleShot(False)
967-
self.saveTimer.timeout.connect(self.saveDatas)
968-
if settings.autoSave:
969-
self.saveTimer.start()
970-
971-
# Set autosave if no changes
972-
self.saveTimerNoChanges = QTimer()
973-
self.saveTimerNoChanges.setInterval(settings.autoSaveNoChangesDelay * 1000)
974-
self.saveTimerNoChanges.setSingleShot(True)
975-
self.mdlFlatData.dataChanged.connect(self.startTimerNoChanges)
976-
self.mdlOutline.dataChanged.connect(self.startTimerNoChanges)
977-
self.mdlCharacter.dataChanged.connect(self.startTimerNoChanges)
978-
self.mdlPlots.dataChanged.connect(self.startTimerNoChanges)
979-
self.mdlWorld.dataChanged.connect(self.startTimerNoChanges)
980-
self.mdlStatus.dataChanged.connect(self.startTimerNoChanges)
981-
self.mdlLabels.dataChanged.connect(self.startTimerNoChanges)
982-
983-
self.saveTimerNoChanges.timeout.connect(self.saveDatas)
984-
self.saveTimerNoChanges.stop()
985-
986-
# UI
987-
for i in [self.actOpen, self.menuRecents]:
988-
i.setEnabled(False)
989-
for i in [self.actSave, self.actSaveAs, self.actCloseProject,
990-
self.menuEdit, self.menuView, self.menuOrganize,
991-
self.menuNavigate,
992-
self.menuTools, self.menuHelp, self.actImport,
993-
self.actCompile, self.actSettings]:
994-
i.setEnabled(True)
995-
# We force to emit even if it opens on the current tab
996-
self.tabMain.currentChanged.emit(settings.lastTab)
997-
998-
# Make sure we can update the window title later.
999-
self.currentProject = project
1000-
self.projectDirty = False
1001-
QSettings().setValue("lastProject", project)
1002-
1003-
item = self.mdlOutline.rootItem
1004-
wc = item.data(Outline.wordCount)
1005-
self.sessionStartWordCount = int(wc) if wc != "" else 0
1006-
# Add project name to Window's name
1007-
self.setWindowTitle(self.projectName() + " - " + self.tr("Manuskript"))
1008-
1009-
# Reset history
1010-
self.history.reset()
1011-
1012-
# Show main Window
1013-
self.switchToProject()
1014-
1015-
def handleUnsavedChanges(self):
1016-
"""
1017-
There may be some currently unsaved changes, but the action the user triggered
1018-
will result in the project or application being closed. To save, or not to save?
1019-
1020-
Or just bail out entirely?
1021-
1022-
Sometimes it is best to just ask.
1023-
"""
1024-
1025-
if not self.projectDirty:
1026-
return True # no unsaved changes, all is good
1027-
1028-
msg = QMessageBox(QMessageBox.Question,
1029-
self.tr("Save project?"),
1030-
"<p><b>" +
1031-
self.tr("Save changes to project \"{}\" before closing?").format(self.projectName()) +
1032-
"</b></p>" +
1033-
"<p>" +
1034-
self.tr("Your changes will be lost if you don't save them.") +
1035-
"</p>",
1036-
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
1037-
1038-
ret = msg.exec()
1039-
1040-
if ret == QMessageBox.Cancel:
1041-
return False # the situation has not been handled, cancel action
1042-
1043-
if ret == QMessageBox.Save:
1044-
self.saveDatas()
1045-
1046-
return True # the situation has been handled
1047-
1048-
1049-
def closeProject(self):
1050-
1051-
if not self.currentProject:
1052-
return
1053-
1054-
# Make sure data is saved.
1055-
if (self.projectDirty and settings.saveOnQuit == True):
1056-
self.saveDatas()
1057-
elif not self.handleUnsavedChanges():
1058-
return # user cancelled action
1059-
1060-
# Close open tabs in editor
1061-
self.mainEditor.closeAllTabs()
1062-
1063-
self.currentProject = None
1064-
self.projectDirty = None
1065-
QSettings().setValue("lastProject", "")
1066-
1067-
# Clear datas
1068-
self.loadEmptyDatas()
1069-
self.saveTimer.stop()
1070-
self.saveTimerNoChanges.stop()
1071-
loadSave.clearSaveCache()
1072-
1073-
self.breakConnections()
1074-
1075-
# UI
1076-
for i in [self.actOpen, self.menuRecents]:
1077-
i.setEnabled(True)
1078-
for i in [self.actSave, self.actSaveAs, self.actCloseProject,
1079-
self.menuEdit, self.menuView, self.menuOrganize,
1080-
self.menuTools, self.menuHelp, self.actImport,
1081-
self.actCompile, self.actSettings]:
1082-
i.setEnabled(False)
1083-
1084-
# Set Window's name - no project loaded
1085-
self.setWindowTitle(self.tr("Manuskript"))
1086-
1087-
# Reload recent files
1088-
self.welcome.updateValues()
1089-
1090-
# Show welcome dialog
1091-
self.switchToWelcome()
1092-
1093910
def readSettings(self):
1094911
# Load State and geometry
1095912
sttgns = QSettings(qApp.organizationName(), qApp.applicationName())
@@ -1128,133 +945,6 @@ def readSettings(self):
1128945
else:
1129946
self._toolbarState = ""
1130947

1131-
def closeEvent(self, event):
1132-
# Specific settings to save before quitting
1133-
settings.lastTab = self.tabMain.currentIndex()
1134-
1135-
if self.currentProject:
1136-
# Remembering the current items (stores outlineItem's ID)
1137-
settings.openIndexes = self.mainEditor.tabSplitter.openIndexes()
1138-
1139-
# Call close on the main window to clean children widgets
1140-
if self.mainEditor:
1141-
self.mainEditor.close()
1142-
1143-
# Save data from models
1144-
if settings.saveOnQuit:
1145-
self.saveDatas()
1146-
elif not self.handleUnsavedChanges():
1147-
event.ignore() # user opted to cancel the close action
1148-
1149-
# closeEvent
1150-
# QMainWindow.closeEvent(self, event) # Causing segfaults?
1151-
1152-
# Close non-modal windows if they are open.
1153-
if self.td:
1154-
self.td.close()
1155-
if self.fw:
1156-
self.fw.close()
1157-
1158-
# User may have canceled close event, so make sure we indeed want to close.
1159-
# This is necessary because self.updateDockVisibility() hides UI elements.
1160-
if event.isAccepted():
1161-
# Save State and geometry and other things
1162-
appSettings = QSettings(qApp.organizationName(), qApp.applicationName())
1163-
appSettings.setValue("geometry", self.saveGeometry())
1164-
appSettings.setValue("windowState", self.saveState())
1165-
appSettings.setValue("metadataState", self.redacMetadata.saveState())
1166-
appSettings.setValue("revisionsState", self.redacMetadata.revisions.saveState())
1167-
appSettings.setValue("splitterRedacH", self.splitterRedacH.saveState())
1168-
appSettings.setValue("splitterRedacV", self.splitterRedacV.saveState())
1169-
appSettings.setValue("toolbar", self.toolbar.saveState())
1170-
1171-
# If we are not in the welcome window, we update the visibility
1172-
# of the docks widgets
1173-
if self.stack.currentIndex() == 1:
1174-
self.updateDockVisibility()
1175-
1176-
# Storing the visibility of docks to restore it on restart
1177-
appSettings.setValue("docks", self._dckVisibility)
1178-
1179-
def startTimerNoChanges(self):
1180-
"""
1181-
Something changed in the project that requires auto-saving.
1182-
"""
1183-
self.projectDirty = True
1184-
1185-
if settings.autoSaveNoChanges:
1186-
self.saveTimerNoChanges.start()
1187-
1188-
def saveDatas(self, projectName=None):
1189-
"""Saves the current project (in self.currentProject).
1190-
1191-
If ``projectName`` is given, currentProject becomes projectName.
1192-
In other words, it "saves as...".
1193-
"""
1194-
1195-
if projectName:
1196-
self.currentProject = projectName
1197-
QSettings().setValue("lastProject", projectName)
1198-
1199-
# Stop the timer before saving: if auto-saving fails (bugs out?) we don't want it
1200-
# to keep trying and continuously hitting the failure condition. Nor do we want to
1201-
# risk a scenario where the timer somehow triggers a new save while saving.
1202-
self.saveTimerNoChanges.stop()
1203-
1204-
if self.currentProject is None:
1205-
# No UI feedback here as this code path indicates a race condition that happens
1206-
# after the user has already closed the project through some way. But in that
1207-
# scenario, this code should not be reachable to begin with.
1208-
LOGGER.error("There is no current project to save.")
1209-
return
1210-
1211-
r = loadSave.saveProject() # version=0
1212-
1213-
projectName = os.path.basename(self.currentProject)
1214-
if r:
1215-
self.projectDirty = False # successful save, clear dirty flag
1216-
1217-
feedback = self.tr("Project {} saved.").format(projectName)
1218-
F.statusMessage(feedback, importance=0)
1219-
LOGGER.info("Project {} saved.".format(projectName))
1220-
else:
1221-
feedback = self.tr("WARNING: Project {} not saved.").format(projectName)
1222-
F.statusMessage(feedback, importance=3)
1223-
LOGGER.warning("Project {} not saved.".format(projectName))
1224-
1225-
def loadEmptyDatas(self):
1226-
self.mdlFlatData = QStandardItemModel(self)
1227-
self.mdlCharacter = characterModel(self)
1228-
self.mdlLabels = QStandardItemModel(self)
1229-
self.mdlStatus = QStandardItemModel(self)
1230-
self.mdlPlots = plotModel(self)
1231-
self.mdlOutline = outlineModel(self)
1232-
self.mdlWorld = worldModel(self)
1233-
1234-
def loadDatas(self, project):
1235-
errors = loadSave.loadProject(project)
1236-
1237-
# Giving some feedback
1238-
if not errors:
1239-
LOGGER.info("Project {} loaded.".format(project))
1240-
F.statusMessage(
1241-
self.tr("Project {} loaded.").format(project), 2000)
1242-
else:
1243-
LOGGER.error("Project {} loaded with some errors:".format(project))
1244-
for e in errors:
1245-
LOGGER.error(" * {} wasn't found in project file.".format(e))
1246-
F.statusMessage(
1247-
self.tr("Project {} loaded with some errors.").format(project), 5000, importance = 3)
1248-
1249-
if project in errors:
1250-
LOGGER.error("Loading project {} failed.".format(project))
1251-
F.statusMessage(
1252-
self.tr("Loading project {} failed.").format(project), 5000, importance = 3)
1253-
1254-
return False
1255-
1256-
return True
1257-
1258948
###############################################################################
1259949
# MAIN CONNECTIONS
1260950
###############################################################################

0 commit comments

Comments
 (0)