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
« 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# ///////////////////////////////////////////////////////////////
6"""EzQt_App — Main QMainWindow subclass for EzQt applications."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14import platform
15import sys
16from pathlib import Path
17from typing import Any, cast
19# Third-party imports
20from PySide6.QtCore import Qt
21from PySide6.QtGui import QMouseEvent, QPixmap, QResizeEvent, QShowEvent
22from PySide6.QtWidgets import QApplication, QMainWindow, QWidget
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
44# ///////////////////////////////////////////////////////////////
45# CONSTANTS
46# ///////////////////////////////////////////////////////////////
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"))
52# ///////////////////////////////////////////////////////////////
53# CLASSES
54# ///////////////////////////////////////////////////////////////
57class EzQt_App(QMainWindow):
58 """
59 Main EzQt_App application.
61 This class represents the main application window
62 with all its components (menu, pages, settings, etc.).
63 """
65 # ///////////////////////////////////////////////////////////////
66 # INIT
67 # ///////////////////////////////////////////////////////////////
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
80 # Load resources and settings
81 AppService.load_fonts_resources()
82 AppService.load_app_settings()
84 self._config_service = get_config_service()
86 # ///////////////////////////////////////////////////////////////
87 # PUBLIC METHODS
88 # ///////////////////////////////////////////////////////////////
90 def no_menu(self) -> EzQt_App:
91 """
92 Disable the left menu for this application instance.
94 Returns:
95 EzQt_App: Self instance for chaining.
96 """
97 self._has_menu = False
98 return self
100 def no_settings_panel(self) -> EzQt_App:
101 """
102 Disable the settings slide-in panel for this application instance.
104 Returns:
105 EzQt_App: Self instance for chaining.
106 """
107 self._has_settings_panel = False
108 return self
110 def build(self) -> EzQt_App:
111 """
112 Explicitly build the UI layout.
114 Automatically called on first show() if not called.
116 Returns:
117 EzQt_App: Self instance for chaining.
118 """
119 if not self._ui_initialized:
120 self._build_ui()
121 return self
123 def showEvent(self, event: QShowEvent) -> None:
124 """
125 Ensure UI is built before showing the window.
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)
134 def set_app_theme(self) -> None:
135 """Update and apply the application theme based on current settings.
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()
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()
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)
162 def refresh_theme(self) -> EzQt_App:
163 """Re-apply the theme stylesheet and polish all widgets.
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.
171 Returns:
172 self: Allows method chaining.
174 Example::
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
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.
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 )
208 def add_menu(self, name: str, icon: str) -> QWidget:
209 """
210 Add a new menu item and corresponding page.
212 Args:
213 name: Label for the menu and page.
214 icon: Icon name or path.
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)
228 return page
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()
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)
242 def resizeEvent(self, _event: QResizeEvent) -> None:
243 """
244 Handle window resize events to update UI components.
246 Args:
247 _event: The QResizeEvent instance (unused).
248 """
249 if self._ui_initialized:
250 UiDefinitionsService.resize_grips(self._as_window())
252 def mousePressEvent(self, event: QMouseEvent) -> None:
253 """
254 Handle mouse press events for window dragging and diagnostics.
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")
272 def set_credits(self, credits: Any) -> None:
273 """
274 Set credit text in the bottom bar.
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)
282 def set_version(self, version: str) -> None:
283 """
284 Set version text in the bottom bar.
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)
292 def get_translation_stats(self) -> dict[str, Any]:
293 """
294 Retrieve current translation statistics.
296 Returns:
297 dict: Statistics about translated and missing strings.
298 """
299 from .services.translation import get_translation_stats
301 return get_translation_stats()
303 def enable_auto_translation(self, enabled: bool = True) -> None:
304 """
305 Enable or disable automatic translation collection.
307 Args:
308 enabled: Whether to enable auto-translation.
309 """
310 from .services.translation import enable_auto_translation
312 enable_auto_translation(enabled)
314 def clear_translation_cache(self) -> None:
315 """Clear the automatic translation cache."""
316 from .services.translation import clear_auto_translation_cache
318 clear_auto_translation_cache()
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.
326 Args:
327 widget: Root widget to start scanning from (default: self).
328 recursive: Whether to scan child widgets recursively.
330 Returns:
331 dict: Summary of the collection process.
332 """
333 from .services.translation import collect_and_compare_strings
335 if widget is None:
336 widget = self
337 return collect_and_compare_strings(widget, recursive)
339 def get_new_strings(self) -> set[str]:
340 """
341 Get all newly discovered strings since last save.
343 Returns:
344 set[str]: Set of new translatable strings.
345 """
346 from .services.translation import get_new_strings
348 return get_new_strings()
350 def get_string_collector_stats(self) -> dict[str, Any]:
351 """
352 Get statistics from the string collector.
354 Returns:
355 dict: Collector statistics.
356 """
357 from .services.translation import get_string_collector_stats
359 return get_string_collector_stats()
361 # ///////////////////////////////////////////////////////////////
362 # PRIVATE METHODS
363 # ///////////////////////////////////////////////////////////////
365 def _as_window(self) -> MainWindowProtocol:
366 """
367 Cast self to MainWindowProtocol.
369 Returns:
370 MainWindowProtocol: Typed reference to self.
371 """
372 return cast(MainWindowProtocol, self)
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.
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.
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()
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
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()
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()
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()
420 return fallback
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
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")
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")
444 # Initialize base components
445 Fonts.initFonts()
446 SizePolicy.initSizePolicy()
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 )
456 settings_service = get_settings_service()
458 # Use custom title bar on Windows
459 settings_service.set_custom_title_bar_enabled(OS_NAME == "Windows")
461 # Set application basic info
462 self.setWindowTitle(settings_service.app.NAME)
463 self.set_app_icon(Images.logo_placeholder, y_shrink=0)
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 )
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()
479 # Apply UI definitions and themes
480 UiDefinitionsService.apply_definitions(self)
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"
494 settings_service.set_theme(_theme)
495 ThemeService.apply_theme(self._as_window())
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()
518 if theme_toggle and hasattr(theme_toggle, "valueChanged"):
519 theme_toggle.valueChanged.connect(self.set_app_theme)
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
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()
536 self._ui_initialized = True
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
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 )