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

255 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 07:07 +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 theme_file_name: str | None = None, 

72 **kwargs: Any, 

73 ) -> None: 

74 """ 

75 Initialize the EzQt_App application. 

76 

77 Args: 

78 theme_file_name: Deprecated — no longer used. All ``.qss`` files 

79 placed under ``bin/themes/`` are now loaded automatically. 

80 Passing a value emits a deprecation warning. 

81 **kwargs: Backward compatibility for legacy arguments (e.g., themeFileName). 

82 """ 

83 QMainWindow.__init__(self) 

84 self._has_menu: bool = True 

85 self._has_settings_panel: bool = True 

86 self._ui_initialized: bool = False 

87 

88 # Deprecation: theme_file_name is no longer used 

89 if theme_file_name is not None: 

90 warn_tech( 

91 code="app.deprecated_arg.theme_file_name", 

92 message=( 

93 "Argument 'theme_file_name' is deprecated and has no effect. " 

94 "Place your .qss files in bin/themes/ — they are loaded automatically." 

95 ), 

96 ) 

97 

98 # Handle backward compatibility 

99 if "themeFileName" in kwargs: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 warn_tech( 

101 code="app.legacy_arg", 

102 message="Argument 'themeFileName' is deprecated. Use 'theme_file_name' instead.", 

103 ) 

104 kwargs.pop("themeFileName") 

105 

106 # Load resources and settings 

107 AppService.load_fonts_resources() 

108 AppService.load_app_settings() 

109 

110 self._config_service = get_config_service() 

111 

112 # /////////////////////////////////////////////////////////////// 

113 # PUBLIC METHODS 

114 # /////////////////////////////////////////////////////////////// 

115 

116 def no_menu(self) -> EzQt_App: 

117 """ 

118 Disable the left menu for this application instance. 

119 

120 Returns: 

121 EzQt_App: Self instance for chaining. 

122 """ 

123 self._has_menu = False 

124 return self 

125 

126 def no_settings_panel(self) -> EzQt_App: 

127 """ 

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

129 

130 Returns: 

131 EzQt_App: Self instance for chaining. 

132 """ 

133 self._has_settings_panel = False 

134 return self 

135 

136 def build(self) -> EzQt_App: 

137 """ 

138 Explicitly build the UI layout. 

139 

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

141 

142 Returns: 

143 EzQt_App: Self instance for chaining. 

144 """ 

145 if not self._ui_initialized: 

146 self._build_ui() 

147 return self 

148 

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

150 """ 

151 Ensure UI is built before showing the window. 

152 

153 Args: 

154 event: The QShowEvent instance. 

155 """ 

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

157 self._build_ui() 

158 super().showEvent(event) 

159 

160 def set_app_theme(self) -> None: 

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

162 

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

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

165 trigger a full UI refresh here. 

166 """ 

167 self.build() 

168 self.update_ui() 

169 

170 def update_ui(self) -> None: 

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

172 self.build() 

173 ThemeService.apply_theme(self._as_window()) 

174 ez_app = EzApplication.instance() 

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

176 ez_app.themeChanged.emit() 

177 self.ui.header_container.update_all_theme_icons() 

178 self.ui.menu_container.update_all_theme_icons() 

179 self.ui.settings_panel.update_all_theme_icons() 

180 

181 QApplication.processEvents() 

182 app_instance = QApplication.instance() 

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

184 for widget in app_instance.allWidgets(): 

185 widget.style().unpolish(widget) 

186 widget.style().polish(widget) 

187 

188 def refresh_theme(self) -> EzQt_App: 

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

190 

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

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

193 evaluated against the newly added widgets. Also refreshes all 

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

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

196 

197 Returns: 

198 self: Allows method chaining. 

199 

200 Example:: 

201 

202 window = EzQt_App().build() 

203 window.show() 

204 add_things_to_my_app(window, Icons) 

205 window.refresh_theme() 

206 """ 

207 ThemeService.apply_theme(self._as_window()) 

208 self.ui.header_container.update_all_theme_icons() 

209 self.ui.menu_container.update_all_theme_icons() 

210 self.ui.settings_panel.update_all_theme_icons() 

211 app_instance = QApplication.instance() 

212 if isinstance(app_instance, QApplication): 

213 for widget in app_instance.allWidgets(): 

214 widget.style().unpolish(widget) 

215 widget.style().polish(widget) 

216 return self 

217 

218 def set_app_icon( 

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

220 ) -> None: 

221 """ 

222 Set the application logo in the header. 

223 

224 Args: 

225 icon: Path to icon or QPixmap object. 

226 y_shrink: Vertical shrink factor. 

227 y_offset: Vertical offset adjustment. 

228 """ 

229 self.build() 

230 return self.ui.header_container.set_app_logo( 

231 logo=icon, y_shrink=y_shrink, y_offset=y_offset 

232 ) 

233 

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

235 """ 

236 Add a new menu item and corresponding page. 

237 

238 Args: 

239 name: Label for the menu and page. 

240 icon: Icon name or path. 

241 

242 Returns: 

243 QWidget: The created page widget. 

244 """ 

245 self.build() 

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

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

248 menu.setProperty("page", page) 

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

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

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

252 menu.clicked.connect(self.switch_menu) 

253 

254 return page 

255 

256 def switch_menu(self) -> None: 

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

258 sender = self.sender() 

259 if not sender: 

260 return 

261 senderName = sender.objectName() 

262 

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

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

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

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

267 

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

269 """ 

270 Handle window resize events to update UI components. 

271 

272 Args: 

273 _event: The QResizeEvent instance (unused). 

274 """ 

275 if self._ui_initialized: 

276 UiDefinitionsService.resize_grips(self._as_window()) 

277 

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

279 """ 

280 Handle mouse press events for window dragging and diagnostics. 

281 

282 Args: 

283 event: The QMouseEvent instance. 

284 """ 

285 if not self._ui_initialized: 

286 return 

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

288 if IS_DEV: 

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

290 if child_widget: 

291 child_name = child_widget.objectName() 

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

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

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

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

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

297 

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

299 """ 

300 Set credit text in the bottom bar. 

301 

302 Args: 

303 credits: Text or object to display as credits. 

304 """ 

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

306 self.ui.bottom_bar.set_credits(credits) 

307 

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

309 """ 

310 Set version text in the bottom bar. 

311 

312 Args: 

313 version: Version string to display. 

314 """ 

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

316 self.ui.bottom_bar.set_version(version) 

317 

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

319 """ 

320 Retrieve current translation statistics. 

321 

322 Returns: 

323 dict: Statistics about translated and missing strings. 

324 """ 

325 from .services.translation import get_translation_stats 

326 

327 return get_translation_stats() 

328 

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

330 """ 

331 Enable or disable automatic translation collection. 

332 

333 Args: 

334 enabled: Whether to enable auto-translation. 

335 """ 

336 from .services.translation import enable_auto_translation 

337 

338 enable_auto_translation(enabled) 

339 

340 def clear_translation_cache(self) -> None: 

341 """Clear the automatic translation cache.""" 

342 from .services.translation import clear_auto_translation_cache 

343 

344 clear_auto_translation_cache() 

345 

346 def collect_strings_for_translation( 

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

348 ) -> dict[str, Any]: 

349 """ 

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

351 

352 Args: 

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

354 recursive: Whether to scan child widgets recursively. 

355 

356 Returns: 

357 dict: Summary of the collection process. 

358 """ 

359 from .services.translation import collect_and_compare_strings 

360 

361 if widget is None: 

362 widget = self 

363 return collect_and_compare_strings(widget, recursive) 

364 

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

366 """ 

367 Get all newly discovered strings since last save. 

368 

369 Returns: 

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

371 """ 

372 from .services.translation import get_new_strings 

373 

374 return get_new_strings() 

375 

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

377 """ 

378 Get statistics from the string collector. 

379 

380 Returns: 

381 dict: Collector statistics. 

382 """ 

383 from .services.translation import get_string_collector_stats 

384 

385 return get_string_collector_stats() 

386 

387 # /////////////////////////////////////////////////////////////// 

388 # PRIVATE METHODS 

389 # /////////////////////////////////////////////////////////////// 

390 

391 def _as_window(self) -> MainWindowProtocol: 

392 """ 

393 Cast self to MainWindowProtocol. 

394 

395 Returns: 

396 MainWindowProtocol: Typed reference to self. 

397 """ 

398 return cast(MainWindowProtocol, self) 

399 

400 def _get_setting_default( 

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

402 ) -> str: 

403 """ 

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

405 

406 Args: 

407 config_data: Full configuration dictionary. 

408 key: Setting key to look for. 

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

410 

411 Returns: 

412 str: The resolved setting value. 

413 """ 

414 root = "settings_panel" 

415 app_section_raw = config_data.get("app") 

416 if isinstance(app_section_raw, dict): 

417 configured_root = app_section_raw.get("settings_storage_root") 

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

419 root = configured_root.strip() 

420 

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

422 current: object = config_data 

423 for part in path_parts: 423 ↛ 427line 423 didn't jump to line 427 because the loop on line 423 didn't complete

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

425 return None 

426 current = current[part] 

427 return current 

428 

429 # 1) Configured storage root 

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

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

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

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

434 return configured_value.strip() 

435 

436 # 2) Legacy root used by existing shipped config 

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

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

439 return legacy_value.strip() 

440 

441 # 3) New root used in some generated configs 

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

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

444 return app_value.strip() 

445 

446 return fallback 

447 

448 def _build_ui(self) -> None: 

449 """Internal UI construction and orchestration.""" 

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

451 return 

452 self._is_building = True 

453 

454 try: 

455 # Load translations 

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

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

458 

459 translation_service = get_translation_service() 

460 if not translation_service.change_language(language_value): 

461 translation_service.change_language_by_code(language_value.lower()) 

462 except Exception as e: 

463 warn_tech( 

464 code="app.language.load_failed", 

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

466 error=e, 

467 ) 

468 get_translation_service().change_language("English") 

469 

470 # Initialize base components 

471 Fonts.initFonts() 

472 SizePolicy.initSizePolicy() 

473 

474 # Set as global widgets 

475 self.ui = Ui_MainWindow() 

476 self.ui.setupUi( 

477 self, 

478 has_menu=self._has_menu, 

479 has_settings_panel=self._has_settings_panel, 

480 ) 

481 

482 settings_service = get_settings_service() 

483 

484 # Use custom title bar on Windows 

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

486 

487 # Set application basic info 

488 self.setWindowTitle(settings_service.app.NAME) 

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

490 

491 # Toggle Menu connection 

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

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

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

495 ) 

496 

497 # Toggle Settings connection 

498 if self._has_settings_panel: 

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

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

501 ) 

502 else: 

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

504 

505 # Apply UI definitions and themes 

506 UiDefinitionsService.apply_definitions(self) 

507 

508 try: 

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

510 app_defaults = app_config.get("app", {}) 

511 fallback_theme = str(app_defaults.get("theme", "dark")) 

512 _theme = self._get_setting_default(app_config, "theme", fallback_theme) 

513 _theme = _theme.lower() 

514 except Exception as e: 

515 warn_tech( 

516 code="app.theme.load_failed", 

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

518 error=e, 

519 ) 

520 _theme = "dark" 

521 

522 settings_service.set_theme(_theme) 

523 ThemeService.apply_theme(self._as_window()) 

524 

525 # Theme selector initialization 

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

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

528 try: 

529 gui = settings_service.gui 

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

531 value_to_display = getattr( 

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

533 ) 

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

535 if display: 

536 theme_toggle.set_value(display) 

537 except Exception as e: 

538 warn_tech( 

539 code="app.theme.initialize_selector_failed", 

540 message="Could not initialize theme selector", 

541 error=e, 

542 ) 

543 self.ui.header_container.update_all_theme_icons() 

544 self.ui.menu_container.update_all_theme_icons() 

545 

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

547 theme_toggle.valueChanged.connect(self.set_app_theme) 

548 

549 # String collection for translation 

550 try: 

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

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

553 _collect_enabled = bool( 

554 _translation_section.get("collect_strings", False) 

555 if isinstance(_translation_section, dict) 

556 else False 

557 ) 

558 except Exception: 

559 _collect_enabled = False 

560 

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

562 self._collect_strings_for_translation() 

563 

564 self._ui_initialized = True 

565 

566 def _collect_strings_for_translation(self) -> None: 

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

568 try: 

569 from .services.translation import collect_and_compare_strings 

570 

571 stats = collect_and_compare_strings(self, recursive=True) 

572 get_printer().debug_msg( 

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

574 ) 

575 except Exception as e: 

576 warn_tech( 

577 code="app.translation.collect_strings_failed", 

578 message="Error during automatic string collection", 

579 error=e, 

580 ) 

581 

582 # /////////////////////////////////////////////////////////////// 

583 # BACKWARD COMPATIBILITY ALIASES (DEPRECATED) 

584 # /////////////////////////////////////////////////////////////// 

585 

586 def setAppTheme(self) -> None: 

587 """ 

588 Deprecated alias for set_app_theme. 

589 .. deprecated:: 1.0.0 

590 """ 

591 warn_tech( 

592 code="app.legacy_method", 

593 message="Method 'setAppTheme' is deprecated. Use 'set_app_theme' instead.", 

594 ) 

595 self.set_app_theme() 

596 

597 def updateUI(self) -> None: 

598 """ 

599 Deprecated alias for update_ui. 

600 .. deprecated:: 1.0.0 

601 """ 

602 warn_tech( 

603 code="app.legacy_method", 

604 message="Method 'updateUI' is deprecated. Use 'update_ui' instead.", 

605 ) 

606 self.update_ui() 

607 

608 def setAppIcon( 

609 self, logo: str | QPixmap, y_shrink: int = 0, y_offset: int = 0 

610 ) -> None: 

611 """ 

612 Deprecated alias for set_app_icon. 

613 .. deprecated:: 1.0.0 

614 """ 

615 warn_tech( 

616 code="app.legacy_method", 

617 message="Method 'setAppIcon' is deprecated. Use 'set_app_icon' instead.", 

618 ) 

619 self.set_app_icon(logo, y_shrink, y_offset) 

620 

621 def addMenu(self, name: str, icon: str) -> QWidget: 

622 """ 

623 Deprecated alias for add_menu. 

624 .. deprecated:: 1.0.0 

625 """ 

626 warn_tech( 

627 code="app.legacy_method", 

628 message="Method 'addMenu' is deprecated. Use 'add_menu' instead.", 

629 ) 

630 return self.add_menu(name, icon) 

631 

632 def switchMenu(self) -> None: 

633 """ 

634 Deprecated alias for switch_menu. 

635 .. deprecated:: 1.0.0 

636 """ 

637 warn_tech( 

638 code="app.legacy_method", 

639 message="Method 'switchMenu' is deprecated. Use 'switch_menu' instead.", 

640 ) 

641 self.switch_menu()