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
« 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# ///////////////////////////////////////////////////////////////
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 theme_file_name: str | None = None,
72 **kwargs: Any,
73 ) -> None:
74 """
75 Initialize the EzQt_App application.
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
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 )
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")
106 # Load resources and settings
107 AppService.load_fonts_resources()
108 AppService.load_app_settings()
110 self._config_service = get_config_service()
112 # ///////////////////////////////////////////////////////////////
113 # PUBLIC METHODS
114 # ///////////////////////////////////////////////////////////////
116 def no_menu(self) -> EzQt_App:
117 """
118 Disable the left menu for this application instance.
120 Returns:
121 EzQt_App: Self instance for chaining.
122 """
123 self._has_menu = False
124 return self
126 def no_settings_panel(self) -> EzQt_App:
127 """
128 Disable the settings slide-in panel for this application instance.
130 Returns:
131 EzQt_App: Self instance for chaining.
132 """
133 self._has_settings_panel = False
134 return self
136 def build(self) -> EzQt_App:
137 """
138 Explicitly build the UI layout.
140 Automatically called on first show() if not called.
142 Returns:
143 EzQt_App: Self instance for chaining.
144 """
145 if not self._ui_initialized:
146 self._build_ui()
147 return self
149 def showEvent(self, event: QShowEvent) -> None:
150 """
151 Ensure UI is built before showing the window.
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)
160 def set_app_theme(self) -> None:
161 """Update and apply the application theme based on current settings.
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()
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()
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)
188 def refresh_theme(self) -> EzQt_App:
189 """Re-apply the theme stylesheet and polish all widgets.
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.
197 Returns:
198 self: Allows method chaining.
200 Example::
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
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.
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 )
234 def add_menu(self, name: str, icon: str) -> QWidget:
235 """
236 Add a new menu item and corresponding page.
238 Args:
239 name: Label for the menu and page.
240 icon: Icon name or path.
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)
254 return page
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()
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)
268 def resizeEvent(self, _event: QResizeEvent) -> None:
269 """
270 Handle window resize events to update UI components.
272 Args:
273 _event: The QResizeEvent instance (unused).
274 """
275 if self._ui_initialized:
276 UiDefinitionsService.resize_grips(self._as_window())
278 def mousePressEvent(self, event: QMouseEvent) -> None:
279 """
280 Handle mouse press events for window dragging and diagnostics.
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")
298 def set_credits(self, credits: Any) -> None:
299 """
300 Set credit text in the bottom bar.
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)
308 def set_version(self, version: str) -> None:
309 """
310 Set version text in the bottom bar.
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)
318 def get_translation_stats(self) -> dict[str, Any]:
319 """
320 Retrieve current translation statistics.
322 Returns:
323 dict: Statistics about translated and missing strings.
324 """
325 from .services.translation import get_translation_stats
327 return get_translation_stats()
329 def enable_auto_translation(self, enabled: bool = True) -> None:
330 """
331 Enable or disable automatic translation collection.
333 Args:
334 enabled: Whether to enable auto-translation.
335 """
336 from .services.translation import enable_auto_translation
338 enable_auto_translation(enabled)
340 def clear_translation_cache(self) -> None:
341 """Clear the automatic translation cache."""
342 from .services.translation import clear_auto_translation_cache
344 clear_auto_translation_cache()
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.
352 Args:
353 widget: Root widget to start scanning from (default: self).
354 recursive: Whether to scan child widgets recursively.
356 Returns:
357 dict: Summary of the collection process.
358 """
359 from .services.translation import collect_and_compare_strings
361 if widget is None:
362 widget = self
363 return collect_and_compare_strings(widget, recursive)
365 def get_new_strings(self) -> set[str]:
366 """
367 Get all newly discovered strings since last save.
369 Returns:
370 set[str]: Set of new translatable strings.
371 """
372 from .services.translation import get_new_strings
374 return get_new_strings()
376 def get_string_collector_stats(self) -> dict[str, Any]:
377 """
378 Get statistics from the string collector.
380 Returns:
381 dict: Collector statistics.
382 """
383 from .services.translation import get_string_collector_stats
385 return get_string_collector_stats()
387 # ///////////////////////////////////////////////////////////////
388 # PRIVATE METHODS
389 # ///////////////////////////////////////////////////////////////
391 def _as_window(self) -> MainWindowProtocol:
392 """
393 Cast self to MainWindowProtocol.
395 Returns:
396 MainWindowProtocol: Typed reference to self.
397 """
398 return cast(MainWindowProtocol, self)
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.
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.
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()
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
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()
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()
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()
446 return fallback
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
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")
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")
470 # Initialize base components
471 Fonts.initFonts()
472 SizePolicy.initSizePolicy()
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 )
482 settings_service = get_settings_service()
484 # Use custom title bar on Windows
485 settings_service.set_custom_title_bar_enabled(OS_NAME == "Windows")
487 # Set application basic info
488 self.setWindowTitle(settings_service.app.NAME)
489 self.set_app_icon(Images.logo_placeholder, y_shrink=0)
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 )
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()
505 # Apply UI definitions and themes
506 UiDefinitionsService.apply_definitions(self)
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"
522 settings_service.set_theme(_theme)
523 ThemeService.apply_theme(self._as_window())
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()
546 if theme_toggle and hasattr(theme_toggle, "valueChanged"):
547 theme_toggle.valueChanged.connect(self.set_app_theme)
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
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()
564 self._ui_initialized = True
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
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 )
582 # ///////////////////////////////////////////////////////////////
583 # BACKWARD COMPATIBILITY ALIASES (DEPRECATED)
584 # ///////////////////////////////////////////////////////////////
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()
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()
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)
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)
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()