Coverage for src / ezqt_app / app.py: 61.64%

233 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 13:12 +0000

1# /////////////////////////////////////////////////////////////// 

2# APP - Main application window 

3# Project: ezqt_app 

4# /////////////////////////////////////////////////////////////// 

5 

6"""EzQt_App — Main QMainWindow subclass for EzQt applications.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import platform 

15import sys 

16from pathlib import Path 

17from typing import Any, cast 

18 

19# Third-party imports 

20from PySide6.QtCore import Qt 

21from PySide6.QtGui import QMouseEvent, QPixmap, QResizeEvent, QShowEvent 

22from PySide6.QtWidgets import QApplication, QMainWindow, QWidget 

23 

24# Local imports 

25from .domain.ports.main_window import MainWindowProtocol 

26from .services.application.app_service import AppService 

27from .services.config import get_config_service 

28from .services.settings import get_settings_service 

29from .services.translation import get_translation_service 

30from .services.ui import ( 

31 Fonts, 

32 MenuService, 

33 PanelService, 

34 SizePolicy, 

35 ThemeService, 

36 UiDefinitionsService, 

37) 

38from .shared.resources import Images 

39from .utils.diagnostics import warn_tech 

40from .utils.printer import get_printer 

41from .widgets.core.ez_app import EzApplication 

42from .widgets.ui_main import Ui_MainWindow 

43 

44# /////////////////////////////////////////////////////////////// 

45# CONSTANTS 

46# /////////////////////////////////////////////////////////////// 

47 

48OS_NAME: str = platform.system() 

49APP_PATH: Path = Path(getattr(sys, "_MEIPASS", Path(sys.argv[0]).resolve().parent)) 

50IS_DEV: bool = bool(not hasattr(sys, "frozen")) 

51 

52# /////////////////////////////////////////////////////////////// 

53# CLASSES 

54# /////////////////////////////////////////////////////////////// 

55 

56 

57class EzQt_App(QMainWindow): 

58 """ 

59 Main EzQt_App application. 

60 

61 This class represents the main application window 

62 with all its components (menu, pages, settings, etc.). 

63 """ 

64 

65 # /////////////////////////////////////////////////////////////// 

66 # INIT 

67 # /////////////////////////////////////////////////////////////// 

68 

69 def __init__( 

70 self, 

71 ) -> None: 

72 """ 

73 Initialize the EzQt_App application. 

74 """ 

75 QMainWindow.__init__(self) 

76 self._has_menu: bool = True 

77 self._has_settings_panel: bool = True 

78 self._ui_initialized: bool = False 

79 

80 # Load resources and settings 

81 AppService.load_fonts_resources() 

82 AppService.load_app_settings() 

83 

84 self._config_service = get_config_service() 

85 

86 # /////////////////////////////////////////////////////////////// 

87 # PUBLIC METHODS 

88 # /////////////////////////////////////////////////////////////// 

89 

90 def no_menu(self) -> EzQt_App: 

91 """ 

92 Disable the left menu for this application instance. 

93 

94 Returns: 

95 EzQt_App: Self instance for chaining. 

96 """ 

97 self._has_menu = False 

98 return self 

99 

100 def no_settings_panel(self) -> EzQt_App: 

101 """ 

102 Disable the settings slide-in panel for this application instance. 

103 

104 Returns: 

105 EzQt_App: Self instance for chaining. 

106 """ 

107 self._has_settings_panel = False 

108 return self 

109 

110 def build(self) -> EzQt_App: 

111 """ 

112 Explicitly build the UI layout. 

113 

114 Automatically called on first show() if not called. 

115 

116 Returns: 

117 EzQt_App: Self instance for chaining. 

118 """ 

119 if not self._ui_initialized: 

120 self._build_ui() 

121 return self 

122 

123 def showEvent(self, event: QShowEvent) -> None: 

124 """ 

125 Ensure UI is built before showing the window. 

126 

127 Args: 

128 event: The QShowEvent instance. 

129 """ 

130 if not self._ui_initialized: 130 ↛ 132line 130 didn't jump to line 132 because the condition on line 130 was always true

131 self._build_ui() 

132 super().showEvent(event) 

133 

134 def set_app_theme(self) -> None: 

135 """Update and apply the application theme based on current settings. 

136 

137 The theme was already persisted and applied to ``SettingsService`` by 

138 ``_on_theme_selector_changed`` before this slot fires. We only need to 

139 trigger a full UI refresh here. 

140 """ 

141 self.build() 

142 self.update_ui() 

143 

144 def update_ui(self) -> None: 

145 """Force a full UI refresh including themes, icons, and styles.""" 

146 self.build() 

147 ThemeService.apply_theme(self._as_window()) 

148 ez_app = EzApplication.instance() 

149 if isinstance(ez_app, EzApplication): 149 ↛ 151line 149 didn't jump to line 151 because the condition on line 149 was always true

150 ez_app.themeChanged.emit() 

151 self.ui.header_container.update_all_theme_icons() 

152 self.ui.menu_container.update_all_theme_icons() 

153 self.ui.settings_panel.update_all_theme_icons() 

154 

155 QApplication.processEvents() 

156 app_instance = QApplication.instance() 

157 if isinstance(app_instance, QApplication): 157 ↛ exitline 157 didn't return from function 'update_ui' because the condition on line 157 was always true

158 for widget in app_instance.allWidgets(): 

159 widget.style().unpolish(widget) 

160 widget.style().polish(widget) 

161 

162 def refresh_theme(self) -> EzQt_App: 

163 """Re-apply the theme stylesheet and polish all widgets. 

164 

165 Call this after adding custom widgets to the application to ensure 

166 that QSS rules (especially ``#objectName`` selectors) are correctly 

167 evaluated against the newly added widgets. Also refreshes all 

168 ``ThemeIcon`` instances so that icons added after ``build()`` (e.g. 

169 via ``add_menu()``) are correctly coloured for the current theme. 

170 

171 Returns: 

172 self: Allows method chaining. 

173 

174 Example:: 

175 

176 window = EzQt_App().build() 

177 window.show() 

178 add_things_to_my_app(window, Icons) 

179 window.refresh_theme() 

180 """ 

181 ThemeService.apply_theme(self._as_window()) 

182 self.ui.header_container.update_all_theme_icons() 

183 self.ui.menu_container.update_all_theme_icons() 

184 self.ui.settings_panel.update_all_theme_icons() 

185 app_instance = QApplication.instance() 

186 if isinstance(app_instance, QApplication): 

187 for widget in app_instance.allWidgets(): 

188 widget.style().unpolish(widget) 

189 widget.style().polish(widget) 

190 return self 

191 

192 def set_app_icon( 

193 self, icon: str | QPixmap, y_shrink: int = 0, y_offset: int = 0 

194 ) -> None: 

195 """ 

196 Set the application logo in the header. 

197 

198 Args: 

199 icon: Path to icon or QPixmap object. 

200 y_shrink: Vertical shrink factor. 

201 y_offset: Vertical offset adjustment. 

202 """ 

203 self.build() 

204 return self.ui.header_container.set_app_logo( 

205 logo=icon, y_shrink=y_shrink, y_offset=y_offset 

206 ) 

207 

208 def add_menu(self, name: str, icon: str) -> QWidget: 

209 """ 

210 Add a new menu item and corresponding page. 

211 

212 Args: 

213 name: Label for the menu and page. 

214 icon: Icon name or path. 

215 

216 Returns: 

217 QWidget: The created page widget. 

218 """ 

219 self.build() 

220 page = self.ui.pages_container.add_page(name) 

221 menu = self.ui.menu_container.add_menu(name, icon) 

222 menu.setProperty("page", page) 

223 if len(self.ui.menu_container.menus) == 1: 

224 menu.setProperty("class", "active") 

225 menu.clicked.connect(lambda: self.ui.pages_container.set_current_widget(page)) 

226 menu.clicked.connect(self.switch_menu) 

227 

228 return page 

229 

230 def switch_menu(self) -> None: 

231 """Update active state of menu buttons based on sender.""" 

232 sender = self.sender() 

233 if not sender: 

234 return 

235 senderName = sender.objectName() 

236 

237 for btnName, _ in self.ui.menu_container.menus.items(): 

238 if senderName == f"menu_{btnName}": 

239 MenuService.deselect_menu(self._as_window(), senderName) 

240 MenuService.select_menu(self._as_window(), senderName) 

241 

242 def resizeEvent(self, _event: QResizeEvent) -> None: 

243 """ 

244 Handle window resize events to update UI components. 

245 

246 Args: 

247 _event: The QResizeEvent instance (unused). 

248 """ 

249 if self._ui_initialized: 

250 UiDefinitionsService.resize_grips(self._as_window()) 

251 

252 def mousePressEvent(self, event: QMouseEvent) -> None: 

253 """ 

254 Handle mouse press events for window dragging and diagnostics. 

255 

256 Args: 

257 event: The QMouseEvent instance. 

258 """ 

259 if not self._ui_initialized: 

260 return 

261 self.dragPos = event.globalPosition().toPoint() 

262 if IS_DEV: 

263 child_widget = self.childAt(event.position().toPoint()) 

264 if child_widget: 

265 child_name = child_widget.objectName() 

266 get_printer().verbose_msg(f"Mouse click on widget: {child_name}") 

267 elif event.buttons() == Qt.MouseButton.LeftButton: 

268 get_printer().verbose_msg("Mouse click: LEFT CLICK") 

269 elif event.buttons() == Qt.MouseButton.RightButton: 

270 get_printer().verbose_msg("Mouse click: RIGHT CLICK") 

271 

272 def set_credits(self, credits: Any) -> None: 

273 """ 

274 Set credit text in the bottom bar. 

275 

276 Args: 

277 credits: Text or object to display as credits. 

278 """ 

279 if hasattr(self.ui, "bottom_bar") and self.ui.bottom_bar: 

280 self.ui.bottom_bar.set_credits(credits) 

281 

282 def set_version(self, version: str) -> None: 

283 """ 

284 Set version text in the bottom bar. 

285 

286 Args: 

287 version: Version string to display. 

288 """ 

289 if hasattr(self.ui, "bottom_bar") and self.ui.bottom_bar: 

290 self.ui.bottom_bar.set_version(version) 

291 

292 def get_translation_stats(self) -> dict[str, Any]: 

293 """ 

294 Retrieve current translation statistics. 

295 

296 Returns: 

297 dict: Statistics about translated and missing strings. 

298 """ 

299 from .services.translation import get_translation_stats 

300 

301 return get_translation_stats() 

302 

303 def enable_auto_translation(self, enabled: bool = True) -> None: 

304 """ 

305 Enable or disable automatic translation collection. 

306 

307 Args: 

308 enabled: Whether to enable auto-translation. 

309 """ 

310 from .services.translation import enable_auto_translation 

311 

312 enable_auto_translation(enabled) 

313 

314 def clear_translation_cache(self) -> None: 

315 """Clear the automatic translation cache.""" 

316 from .services.translation import clear_auto_translation_cache 

317 

318 clear_auto_translation_cache() 

319 

320 def collect_strings_for_translation( 

321 self, widget: QWidget | None = None, recursive: bool = True 

322 ) -> dict[str, Any]: 

323 """ 

324 Scan widgets for translatable strings and add them to the collector. 

325 

326 Args: 

327 widget: Root widget to start scanning from (default: self). 

328 recursive: Whether to scan child widgets recursively. 

329 

330 Returns: 

331 dict: Summary of the collection process. 

332 """ 

333 from .services.translation import collect_and_compare_strings 

334 

335 if widget is None: 

336 widget = self 

337 return collect_and_compare_strings(widget, recursive) 

338 

339 def get_new_strings(self) -> set[str]: 

340 """ 

341 Get all newly discovered strings since last save. 

342 

343 Returns: 

344 set[str]: Set of new translatable strings. 

345 """ 

346 from .services.translation import get_new_strings 

347 

348 return get_new_strings() 

349 

350 def get_string_collector_stats(self) -> dict[str, Any]: 

351 """ 

352 Get statistics from the string collector. 

353 

354 Returns: 

355 dict: Collector statistics. 

356 """ 

357 from .services.translation import get_string_collector_stats 

358 

359 return get_string_collector_stats() 

360 

361 # /////////////////////////////////////////////////////////////// 

362 # PRIVATE METHODS 

363 # /////////////////////////////////////////////////////////////// 

364 

365 def _as_window(self) -> MainWindowProtocol: 

366 """ 

367 Cast self to MainWindowProtocol. 

368 

369 Returns: 

370 MainWindowProtocol: Typed reference to self. 

371 """ 

372 return cast(MainWindowProtocol, self) 

373 

374 def _get_setting_default( 

375 self, config_data: dict[str, Any], key: str, fallback: str 

376 ) -> str: 

377 """ 

378 Resolve a settings default from configured root with legacy fallback. 

379 

380 Args: 

381 config_data: Full configuration dictionary. 

382 key: Setting key to look for. 

383 fallback: Value to return if key is not found. 

384 

385 Returns: 

386 str: The resolved setting value. 

387 """ 

388 root = "settings_panel" 

389 app_section_raw = config_data.get("app") 

390 if isinstance(app_section_raw, dict): 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 configured_root = app_section_raw.get("settings_storage_root") 

392 if isinstance(configured_root, str) and configured_root.strip(): 

393 root = configured_root.strip() 

394 

395 def _read_nested(path_parts: list[str]) -> object | None: 

396 current: object = config_data 

397 for part in path_parts: 

398 if not isinstance(current, dict) or part not in current: 

399 return None 

400 current = current[part] 

401 return current 

402 

403 # 1) Configured storage root 

404 configured_parts = [part for part in root.split(".") if part] 

405 if configured_parts: 405 ↛ 411line 405 didn't jump to line 411 because the condition on line 405 was always true

406 configured_value = _read_nested([*configured_parts, key, "default"]) 

407 if isinstance(configured_value, str) and configured_value.strip(): 

408 return configured_value.strip() 

409 

410 # 2) Legacy root used by existing shipped config 

411 legacy_value = _read_nested(["settings_panel", key, "default"]) 

412 if isinstance(legacy_value, str) and legacy_value.strip(): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true

413 return legacy_value.strip() 

414 

415 # 3) New root used in some generated configs 

416 app_value = _read_nested(["app", "settings_panel", key, "default"]) 

417 if isinstance(app_value, str) and app_value.strip(): 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 return app_value.strip() 

419 

420 return fallback 

421 

422 def _build_ui(self) -> None: 

423 """Internal UI construction and orchestration.""" 

424 if getattr(self, "_is_building", False): 

425 return 

426 self._is_building = True 

427 

428 try: 

429 # Load translations 

430 app_config = self._config_service.load_config("app") 

431 language_value = self._get_setting_default(app_config, "language", "en") 

432 

433 translation_service = get_translation_service() 

434 if not translation_service.change_language(language_value): 

435 translation_service.change_language_by_code(language_value.lower()) 

436 except Exception as e: 

437 warn_tech( 

438 code="app.language.load_failed", 

439 message="Failed to load language configuration; falling back to English", 

440 error=e, 

441 ) 

442 get_translation_service().change_language("English") 

443 

444 # Initialize base components 

445 Fonts.initFonts() 

446 SizePolicy.initSizePolicy() 

447 

448 # Set as global widgets 

449 self.ui = Ui_MainWindow() 

450 self.ui.setupUi( 

451 self, 

452 has_menu=self._has_menu, 

453 has_settings_panel=self._has_settings_panel, 

454 ) 

455 

456 settings_service = get_settings_service() 

457 

458 # Use custom title bar on Windows 

459 settings_service.set_custom_title_bar_enabled(OS_NAME == "Windows") 

460 

461 # Set application basic info 

462 self.setWindowTitle(settings_service.app.NAME) 

463 self.set_app_icon(Images.logo_placeholder, y_shrink=0) 

464 

465 # Toggle Menu connection 

466 if self._has_menu and self.ui.menu_container.toggle_button: 

467 self.ui.menu_container.toggle_button.clicked.connect( 

468 lambda: PanelService.toggle_menu_panel(self._as_window(), True) 

469 ) 

470 

471 # Toggle Settings connection 

472 if self._has_settings_panel: 

473 self.ui.header_container.settings_btn.clicked.connect( 

474 lambda: PanelService.toggle_settings_panel(self._as_window(), True) 

475 ) 

476 else: 

477 self.ui.header_container.settings_btn.hide() 

478 

479 # Apply UI definitions and themes 

480 UiDefinitionsService.apply_definitions(self) 

481 

482 try: 

483 app_config = self._config_service.load_config("app") 

484 _theme = self._get_setting_default(app_config, "theme", "dark") 

485 _theme = _theme.lower() 

486 except Exception as e: 

487 warn_tech( 

488 code="app.theme.load_failed", 

489 message="Failed to load theme configuration; falling back to dark", 

490 error=e, 

491 ) 

492 _theme = "dark" 

493 

494 settings_service.set_theme(_theme) 

495 ThemeService.apply_theme(self._as_window()) 

496 

497 # Theme selector initialization 

498 theme_toggle = self.ui.settings_panel.get_theme_selector() 

499 if theme_toggle and hasattr(theme_toggle, "set_value"): 

500 try: 

501 gui = settings_service.gui 

502 internal = f"{gui.THEME_PRESET}:{gui.THEME}" 

503 value_to_display = getattr( 

504 self.ui.settings_panel, "_theme_value_to_display", {} 

505 ) 

506 display = value_to_display.get(internal, "") 

507 if display: 

508 theme_toggle.set_value(display) 

509 except Exception as e: 

510 warn_tech( 

511 code="app.theme.initialize_selector_failed", 

512 message="Could not initialize theme selector", 

513 error=e, 

514 ) 

515 self.ui.header_container.update_all_theme_icons() 

516 self.ui.menu_container.update_all_theme_icons() 

517 

518 if theme_toggle and hasattr(theme_toggle, "valueChanged"): 

519 theme_toggle.valueChanged.connect(self.set_app_theme) 

520 

521 # String collection for translation 

522 try: 

523 _translation_cfg = self._config_service.load_config("translation") 

524 _translation_section = _translation_cfg.get("translation", {}) 

525 _collect_enabled = bool( 

526 _translation_section.get("collect_strings", False) 

527 if isinstance(_translation_section, dict) 

528 else False 

529 ) 

530 except Exception: 

531 _collect_enabled = False 

532 

533 if _collect_enabled: 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true

534 self._collect_strings_for_translation() 

535 

536 self._ui_initialized = True 

537 

538 def _collect_strings_for_translation(self) -> None: 

539 """Internal helper for automatic string collection.""" 

540 try: 

541 from .services.translation import collect_and_compare_strings 

542 

543 stats = collect_and_compare_strings(self, recursive=True) 

544 get_printer().debug_msg( 

545 f"[TranslationService] Automatic collection completed: {stats['new_strings']} new strings found" 

546 ) 

547 except Exception as e: 

548 warn_tech( 

549 code="app.translation.collect_strings_failed", 

550 message="Error during automatic string collection", 

551 error=e, 

552 )