Coverage for main/ui/main_window.py: 96%

183 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 15:51 +0000

1import logging 

2import os 

3 

4from PyQt6 import QtCore, QtWidgets 

5from PyQt6.QtCore import Qt 

6from PyQt6.QtWidgets import ( 

7 QMainWindow, QVBoxLayout, 

8 QSplitter, QMenuBar, QHBoxLayout, QSizePolicy, QMessageBox, QApplication, 

9) 

10from PyQt6.QtGui import QAction, QActionGroup 

11 

12from threads.utils_threads import CopyDeleteThread 

13from logger import get_logger, set_log_level 

14 

15log = get_logger() 

16 

17 

18class MainWindow(QMainWindow): 

19 """ 

20 Main application window for the GliAAns UI. 

21 

22 This class represents the primary interface window of the application. 

23 It handles: 

24 - UI setup and layout management 

25 - Menu bar and actions 

26 - Language switching 

27 - Threaded file operations (copy/delete) 

28 - Logging level control 

29 - Workspace management (clear/export) 

30 

31 Attributes: 

32 context (dict): Shared context containing references to settings, workspace path, 

33 signals, and external UI components. 

34 settings (QSettings): Application settings instance. 

35 workspace_path (str): Path to the active workspace. 

36 language_actions (dict): Mapping between language codes and QAction objects. 

37 threads (list): List of active `CopyDeleteThread` instances. 

38 """ 

39 

40 def __init__(self, context): 

41 """ 

42 Initialize the main window and setup UI components. 

43 

44 Args: 

45 context (dict): Application context containing shared data and signals. 

46 """ 

47 super().__init__() 

48 self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) 

49 

50 self.context = context 

51 self.context["language_changed"].connect(self._translate_ui) 

52 self.settings = self.context["settings"] 

53 self.workspace_path = self.context["workspace_path"] 

54 self.language_actions = {} 

55 

56 self.threads = [] 

57 

58 # Initialize UI and menus 

59 self._setup_ui() 

60 self._setup_menus() 

61 self._translate_ui() 

62 

63 # Closing the app when this window is closed 

64 self.destroyed.connect(QApplication.instance().quit) 

65 

66 # -------------------------- 

67 # UI SETUP 

68 # -------------------------- 

69 def _setup_ui(self): 

70 """Configure the main window layout and structure.""" 

71 self.resize(950, 700) 

72 self.setMinimumSize(950, 700) 

73 

74 central_widget = QtWidgets.QWidget() 

75 self.setCentralWidget(central_widget) 

76 self.main_layout = QVBoxLayout(central_widget) 

77 

78 self._setup_splitter() 

79 self._setup_footer() 

80 

81 def _setup_splitter(self): 

82 """Setup the horizontal splitter dividing the main UI panels.""" 

83 self.splitter = QSplitter(QtCore.Qt.Orientation.Horizontal) 

84 self.main_layout.addWidget(self.splitter) 

85 

86 def _setup_footer(self): 

87 """Create and configure the footer containing navigation buttons.""" 

88 self.footer = QtWidgets.QWidget() 

89 self.footer.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) 

90 self.footer_layout = QHBoxLayout(self.footer) 

91 self.footer_layout.setContentsMargins(0, 0, 0, 0) 

92 

93 self.next_button, self.back_button = self.context["create_buttons"]() 

94 self.footer_layout.addWidget(self.back_button, 0, Qt.AlignmentFlag.AlignLeft) 

95 self.footer_layout.addWidget(self.next_button, 0, Qt.AlignmentFlag.AlignRight) 

96 

97 self.main_layout.addWidget(self.footer) 

98 

99 def _setup_menus(self): 

100 """Initialize and configure the menu bar and all menu actions.""" 

101 self.menu_bar = QMenuBar() 

102 self.setMenuBar(self.menu_bar) 

103 

104 # --- File menu --- 

105 self.file_menu = self.menu_bar.addMenu("File") 

106 self.import_file_action = QAction("Import file",self) 

107 self.import_folder_action = QAction("Import Folder", self) 

108 

109 self.export_action = QAction("Export file/folder", self) 

110 

111 self.file_menu.addAction(self.import_file_action) 

112 self.file_menu.addSeparator() 

113 self.file_menu.addAction(self.import_folder_action) 

114 self.file_menu.addAction(self.export_action) 

115 

116 self.import_file_action.triggered.connect(lambda: self.context["tree_view"].add_file_to_workspace(folder_path=None,is_dir=False)) 

117 

118 if "import_page" in self.context and self.context["import_page"]: 

119 self.import_folder_action.triggered.connect(self.context["import_page"].open_folder_dialog) 

120 else: 

121 log.critical("Import page not in context") 

122 

123 self.export_action.triggered.connect(self.export_file_info) 

124 

125 

126 # --- Workspace menu --- 

127 self.workspace_menu = self.menu_bar.addMenu("Workspace") 

128 self.clear_all_action = QAction("Clear workspace", self) 

129 self.export_workspace_action = QAction("Export workspace", self) 

130 self.clear_pipeline_outputs_action = QAction("Clear pipeline outputs", self) 

131 

132 self.workspace_menu.addAction(self.clear_all_action) 

133 self.workspace_menu.addAction(self.export_workspace_action) 

134 self.workspace_menu.addSeparator() 

135 self.workspace_menu.addAction(self.clear_pipeline_outputs_action) 

136 

137 self.clear_all_action.triggered.connect( 

138 lambda: self.clear_folder(folder_path=self.workspace_path, 

139 folder_name="workspace", 

140 return_to_import=True) 

141 ) 

142 self.export_workspace_action.triggered.connect( 

143 lambda: self.context["tree_view"].export_files(self.workspace_path, is_dir=True) 

144 ) 

145 self.clear_pipeline_outputs_action.triggered.connect( 

146 lambda: self.clear_folder(folder_path=os.path.join(self.workspace_path, "pipeline"), 

147 folder_name="outputs", 

148 return_to_import=False) 

149 ) 

150 

151 # --- Settings menu --- 

152 self.settings_menu = self.menu_bar.addMenu("Settings") 

153 self.language_menu = self.settings_menu.addMenu("Language") 

154 self.language_action_group = QActionGroup(self) 

155 self.language_action_group.setExclusive(True) 

156 

157 self._add_language_option("English", "en") 

158 self._add_language_option("Italiano", "it") 

159 

160 self.debug_log_action = QAction("Debug Log", self) 

161 self.debug_log_action.setCheckable(True) 

162 

163 # Restore saved debug setting (default: False) 

164 debug_enabled = self.settings.value("debug_log", False, type=bool) 

165 self.debug_log_action.setChecked(debug_enabled) 

166 self.debug_log_action.toggled.connect(self.toggle_debug_log) 

167 self.settings_menu.addAction(self.debug_log_action) 

168 

169 # --- Help menu --- 

170 self.help_menu = self.menu_bar.addMenu("Help") 

171 

172 def _add_language_option(self, name, code): 

173 """ 

174 Add a selectable language option to the language menu. 

175 

176 Args: 

177 name (str): Human-readable language name. 

178 code (str): Language code (e.g., 'en', 'it'). 

179 """ 

180 action = QAction(name, self, checkable=True) 

181 self.language_action_group.addAction(action) 

182 self.language_menu.addAction(action) 

183 action.triggered.connect(lambda: self.set_language(code)) 

184 self.language_actions[code] = action 

185 

186 # Restore saved language preference 

187 if self.settings.value("language", "en", type=str) == code: 

188 self.language_actions[code].setChecked(True) 

189 

190 def set_language(self, lang_code): 

191 """ 

192 Emit a signal to update the UI language. 

193 

194 Args: 

195 lang_code (str): ISO code of the selected language. 

196 """ 

197 self.context["language_changed"].emit(lang_code) 

198 if lang_code in self.language_actions: 

199 self.language_actions[lang_code].setChecked(True) 

200 

201 def clear_folder(self, folder_path, folder_name, return_to_import): 

202 """ 

203 Clear all files/folders within a given directory using threaded deletion. 

204 

205 Args: 

206 folder_path (str): Target folder path to clear. 

207 folder_name (str): User-friendly name for the confirmation dialog. 

208 return_to_import (bool): If True, returns the user to the import page after clearing. 

209 """ 

210 message = QtCore.QCoreApplication.translate( 

211 "MainWindow", 

212 "Are you sure you want to delete completely {0}?\n" 

213 ).format(folder_name) 

214 

215 if return_to_import: 

216 message += QtCore.QCoreApplication.translate( 

217 "MainWindow", 

218 "WARNING: All data will be removed and you will be returned to the import page." 

219 ) 

220 

221 reply = QMessageBox.question( 

222 self, 

223 QtCore.QCoreApplication.translate("MainWindow", "Confirm deletion"), 

224 message, 

225 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 

226 QMessageBox.StandardButton.No 

227 ) 

228 

229 if reply == QMessageBox.StandardButton.Yes: 

230 for item in os.listdir(folder_path): 

231 item_path = os.path.join(folder_path, item) 

232 self.threads.append(CopyDeleteThread( 

233 src=item_path, 

234 is_folder=os.path.isdir(item_path), 

235 delete=True 

236 )) 

237 self.threads[-1].error.connect( 

238 lambda msg, it=item: self.copydelete_thread_error(f"Error while clearing {it}: {msg}") 

239 ) 

240 self.threads[-1].finished.connect( 

241 lambda msg, show=False: self.copydelete_thread_success(msg, show) 

242 ) 

243 self.threads[-1].start() 

244 

245 log.info(f"{folder_name} emptied") 

246 

247 if return_to_import and "return_to_import" in self.context: 

248 self.context["return_to_import"]() 

249 

250 def set_widgets(self, left_widget, right_widget): 

251 """ 

252 Set the main splitter widgets (left = navigation, right = content). 

253 

254 Args: 

255 left_widget (QWidget): Left-side panel widget (e.g., tree view). 

256 right_widget (QWidget): Right-side content widget. 

257 """ 

258 if self.splitter.count() > 1: 

259 self.splitter.replaceWidget(1, right_widget) 

260 self.splitter.setSizes([200, 600]) 

261 self.left_panel.adjust_tree_columns() 

262 else: 

263 self.splitter.addWidget(left_widget) 

264 self.left_panel = left_widget 

265 self.splitter.addWidget(right_widget) 

266 self.right_panel = right_widget 

267 self.splitter.setSizes([200, 600]) 

268 self.left_panel.adjust_tree_columns() 

269 self.left_panel.new_thread.connect(self.new_thread) 

270 

271 def copydelete_thread_error(self, msg): 

272 """ 

273 Handle errors emitted by background copy/delete threads. 

274 

275 Args: 

276 msg (str): Error message from the thread. 

277 """ 

278 QMessageBox.warning(self, "Error", msg) 

279 log.error(msg) 

280 thread_to_remove = self.sender() 

281 if thread_to_remove in self.threads: 

282 self.threads.remove(thread_to_remove) 

283 log.error(f"Error while copying/deleting: {msg}") 

284 

285 def copydelete_thread_success(self, msg, show=True): 

286 """ 

287 Handle successful completion of copy/delete threads. 

288 

289 Args: 

290 msg (str): Success message from the thread. 

291 show (bool): If True, display a success message to the user. 

292 """ 

293 if show: 

294 QMessageBox.information( 

295 self, 

296 QtCore.QCoreApplication.translate("MainWindow", "Success!"), 

297 msg 

298 ) 

299 

300 thread_to_remove = self.sender() 

301 if self.context and "update_main_buttons" in self.context: 

302 self.context["update_main_buttons"]() 

303 log.debug("Success: " + msg) 

304 if thread_to_remove in self.threads: 

305 self.threads.remove(thread_to_remove) 

306 

307 def closeEvent(self, event): 

308 """ 

309 Override close event to safely terminate all running threads. 

310 Args: 

311 event (QCloseEvent): The close event. 

312 """ 

313 if hasattr(self, "threads"): 

314 log.debug(f"Close event triggered. Waiting for {len(self.threads)} active threads...") 

315 

316 while self.threads: 

317 thread = self.threads.pop(0) 

318 

319 log.debug(f"Waiting for thread {thread} to finish...") 

320 

321 try: 

322 thread.finished.disconnect() 

323 except TypeError: 

324 pass 

325 

326 try: 

327 thread.error.disconnect() 

328 except TypeError: 

329 pass 

330 

331 thread.wait(5000) 

332 

333 log.debug("All threads stopped. Accepting close event.") 

334 

335 event.accept() 

336 

337 def export_file_info(self): 

338 """Display information about how to export files/folders.""" 

339 QMessageBox.information( 

340 self, 

341 QtCore.QCoreApplication.translate("MainWindow", "Export file info"), 

342 QtCore.QCoreApplication.translate("MainWindow", "To export a file/folder, right click on it in the left view") 

343 ) 

344 

345 def new_thread(self, thread): 

346 """ 

347 Add and start a new background thread for copy/delete operations. 

348 

349 Args: 

350 thread (CopyDeleteThread): Thread instance to start. 

351 """ 

352 self.threads.append(thread) 

353 self.threads[-1].error.connect(lambda msg: self.copydelete_thread_error(f"Error: {msg}")) 

354 self.threads[-1].finished.connect(self.copydelete_thread_success) 

355 self.threads[-1].start() 

356 

357 def toggle_debug_log(self, checked): 

358 """ 

359 Enable or disable debug logging dynamically. 

360 

361 Args: 

362 checked (bool): True to enable debug logging, False to disable. 

363 """ 

364 self.settings.setValue("debug_log", checked) 

365 if checked: 

366 set_log_level(logging.DEBUG) 

367 else: 

368 set_log_level(logging.ERROR) 

369 

370 def _translate_ui(self): 

371 """Update all translatable UI text elements based on the current language.""" 

372 self.setWindowTitle(QtCore.QCoreApplication.translate("MainWindow", "GliAAns UI")) 

373 self.file_menu.setTitle(QtCore.QCoreApplication.translate("MainWindow", "File")) 

374 self.workspace_menu.setTitle(QtCore.QCoreApplication.translate("MainWindow", "Workspace")) 

375 self.settings_menu.setTitle(QtCore.QCoreApplication.translate("MainWindow", "Settings")) 

376 self.help_menu.setTitle(QtCore.QCoreApplication.translate("MainWindow", "Help")) 

377 self.language_menu.setTitle(QtCore.QCoreApplication.translate("MainWindow", "Language")) 

378 

379 self.import_folder_action.setText(QtCore.QCoreApplication.translate("MainWindow", "Import folder")) 

380 #self.import_folder_action.setText(QtCore.QCoreApplication.translate("MainWindow", "Import file")) 

381 self.export_action.setText(QtCore.QCoreApplication.translate("MainWindow", "Export file/folder")) 

382 self.clear_all_action.setText(QtCore.QCoreApplication.translate("MainWindow", "Clear workspace")) 

383 self.export_workspace_action.setText(QtCore.QCoreApplication.translate("MainWindow", "Export Workspace")) 

384 self.clear_pipeline_outputs_action.setText(QtCore.QCoreApplication.translate("MainWindow", "Clear pipeline outputs")) 

385 self.language_actions["en"].setText(QtCore.QCoreApplication.translate("MainWindow", "English")) 

386 self.language_actions["it"].setText(QtCore.QCoreApplication.translate("MainWindow", "Italiano")) 

387 

388 self.next_button.setText(QtCore.QCoreApplication.translate("MainWindow", "Next")) 

389 self.back_button.setText(QtCore.QCoreApplication.translate("MainWindow", "Back"))