Coverage for src / ezqt_app / widgets / core / settings_panel.py: 49.67%
363 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# WIDGETS.CORE.SETTINGS_PANEL - Settings panel widget
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""SettingsPanel widget with scrollable settings and YAML persistence."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14from typing import Any
16# Third-party imports
17from PySide6.QtCore import QEvent, QSize, Qt, Signal
18from PySide6.QtWidgets import (
19 QFrame,
20 QLabel,
21 QScrollArea,
22 QSizePolicy,
23 QVBoxLayout,
24 QWidget,
25)
27# Local imports
28from ...services.settings import get_settings_service
29from ...services.translation import get_translation_service
30from ...services.ui import Fonts
31from ...utils.diagnostics import warn_tech
34# ///////////////////////////////////////////////////////////////
35# CLASSES
36# ///////////////////////////////////////////////////////////////
37class SettingsPanel(QFrame):
38 """
39 This class is used to create a settings panel.
40 It contains a top border, a content settings frame and a theme settings container.
41 The settings panel is used to display the settings.
42 """
44 # Signal emitted when a setting changes
45 settingChanged = Signal(str, object) # key, value
46 # Signal emitted when language changes
47 languageChanged = Signal()
49 # ///////////////////////////////////////////////////////////////
51 def _settings_storage_prefix(self) -> list[str]:
52 """Return config keys (file name + nested path) used to persist setting values."""
53 config_name = "app"
54 try:
55 from ...services.config import get_config_service
57 app_config = get_config_service().load_config(config_name)
58 app_section = app_config.get("app", {})
59 root = str(app_section.get("settings_storage_root", "settings_panel"))
60 parts = [part for part in root.split(".") if part]
62 def _exists(path_parts: list[str]) -> bool:
63 current: object = app_config
64 for part in path_parts:
65 if not isinstance(current, dict) or part not in current:
66 return False
67 current = current[part]
68 return isinstance(current, dict)
70 if parts and _exists(parts):
71 return [config_name, *parts]
72 except Exception as e:
73 warn_tech(
74 code="widgets.settings_panel.storage_prefix_resolution_failed",
75 message="Could not resolve settings storage prefix from app config",
76 error=e,
77 )
78 return [config_name, "settings_panel"]
80 def _sync_theme_selector_with_settings(self) -> None:
81 """Align theme selector UI with the currently active settings theme."""
82 try:
83 if not hasattr(self, "_theme_selector"):
84 return
86 theme_selector = self._theme_selector
87 gui = get_settings_service().gui
88 current_internal = f"{gui.THEME_PRESET}:{gui.THEME}"
89 current_display = self._theme_value_to_display.get(current_internal, "")
91 if current_display and hasattr(theme_selector, "set_value"):
92 theme_selector.set_value(current_display)
93 except Exception as e:
94 warn_tech(
95 code="widgets.settings_panel.theme_selector_sync_failed",
96 message="Could not synchronize theme selector with active settings",
97 error=e,
98 )
100 def __init__(
101 self,
102 parent: QWidget | None = None,
103 width: int = 240,
104 load_from_yaml: bool = True,
105 ) -> None:
106 super().__init__(parent)
107 self._widgets: list[QWidget] = []
108 self._settings: dict[str, Any] = {}
110 # Store configuration
111 self._width = width
113 self.setObjectName("settings_panel")
114 self.setMinimumSize(QSize(0, 0))
115 self.setMaximumSize(QSize(0, 16777215))
116 self.setFrameShape(QFrame.Shape.NoFrame)
117 self.setFrameShadow(QFrame.Shadow.Raised)
119 # ////// SETUP MAIN LAYOUT
120 self._layout = QVBoxLayout(self)
121 self._layout.setSpacing(0)
122 self._layout.setObjectName("settings_layout")
123 self._layout.setContentsMargins(0, 0, 0, 0)
125 # ////// SETUP TOP BORDER
126 self._top_border = QFrame(self)
127 self._top_border.setObjectName("settings_top_border")
128 self._top_border.setMaximumSize(QSize(16777215, 3))
129 self._top_border.setFrameShape(QFrame.Shape.NoFrame)
130 self._top_border.setFrameShadow(QFrame.Shadow.Raised)
131 self._layout.addWidget(self._top_border)
133 # ////// SETUP SCROLL AREA
134 self._scroll_area = QScrollArea(self)
135 self._scroll_area.setObjectName("settings_scroll_area")
136 self._scroll_area.setWidgetResizable(True)
137 self._scroll_area.setHorizontalScrollBarPolicy(
138 Qt.ScrollBarPolicy.ScrollBarAlwaysOff
139 )
140 self._scroll_area.setVerticalScrollBarPolicy(
141 Qt.ScrollBarPolicy.ScrollBarAsNeeded
142 )
143 self._scroll_area.setFrameShape(QFrame.Shape.NoFrame)
144 self._scroll_area.setFrameShadow(QFrame.Shadow.Raised)
145 self._layout.addWidget(self._scroll_area)
147 # ////// SETUP CONTENT WIDGET
148 self._content_widget = QFrame()
149 self._content_widget.setObjectName("content_widget")
150 self._content_widget.setFrameShape(QFrame.Shape.NoFrame)
151 self._content_widget.setFrameShadow(QFrame.Shadow.Raised)
152 self._content_widget.setSizePolicy(
153 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
154 )
155 self._scroll_area.setWidget(self._content_widget)
157 self._content_layout = QVBoxLayout(self._content_widget)
158 self._content_layout.setObjectName("content_layout")
159 self._content_layout.setSpacing(0)
160 self._content_layout.setContentsMargins(0, 0, 0, 0)
161 self._content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
163 # ////// SETUP THEME SECTION
164 self._theme_section_frame = QFrame(self._content_widget)
165 self._theme_section_frame.setObjectName("theme_section_frame")
166 self._theme_section_frame.setFrameShape(QFrame.Shape.NoFrame)
167 self._theme_section_frame.setFrameShadow(QFrame.Shadow.Raised)
168 self._content_layout.addWidget(
169 self._theme_section_frame, 0, Qt.AlignmentFlag.AlignTop
170 )
172 self._theme_section_layout = QVBoxLayout(self._theme_section_frame)
173 self._theme_section_layout.setSpacing(8)
174 self._theme_section_layout.setObjectName("theme_section_layout")
175 self._theme_section_layout.setContentsMargins(10, 10, 10, 10)
177 # Build theme options from palette (display label → internal value mapping).
178 from ...services.ui.theme_service import ThemeService
180 _theme_options_data = ThemeService.get_available_themes()
181 self._theme_options_map: dict[str, str] = dict(_theme_options_data)
182 self._theme_value_to_display: dict[str, str] = {
183 v: d for d, v in _theme_options_data
184 }
186 _gui = get_settings_service().gui
187 _current_internal = f"{_gui.THEME_PRESET}:{_gui.THEME}"
188 _current_display = self._theme_value_to_display.get(
189 _current_internal,
190 _theme_options_data[0][0] if _theme_options_data else "",
191 )
193 from ...widgets.extended.setting_widgets import SettingSelect
195 self._theme_selector = SettingSelect(
196 label="Active Theme",
197 description="",
198 options=[d for d, _ in _theme_options_data],
199 default=_current_display,
200 )
201 self._theme_selector.setObjectName("theme_selector")
202 self._theme_selector.setSizePolicy(
203 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
204 )
205 self._widgets.append(self._theme_selector)
206 self._theme_selector.valueChanged.connect(
207 lambda _key, display_val: self._on_theme_selector_changed(display_val)
208 )
209 self._theme_section_layout.addWidget(self._theme_selector)
211 if load_from_yaml:
212 self.load_settings_from_yaml()
214 self.settingChanged.connect(self._on_setting_changed)
216 # ///////////////////////////////////////////////////////////////
218 def load_settings_from_yaml(self) -> None:
219 """Load settings from YAML file."""
220 try:
221 from pathlib import Path
223 import yaml
225 possible_paths = [
226 Path.cwd() / "bin" / "config" / "app.config.yaml",
227 Path(__file__).parent.parent.parent
228 / "resources"
229 / "config"
230 / "app.config.yaml",
231 ]
233 app_config = None
234 for path in possible_paths: 234 ↛ 240line 234 didn't jump to line 240 because the loop on line 234 didn't complete
235 if path.exists():
236 with open(path, encoding="utf-8") as f:
237 app_config = yaml.safe_load(f)
238 break
240 if app_config is None: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 warn_tech(
242 code="widgets.settings_panel.app_config_yaml_not_found",
243 message="Could not find app.config.yaml file",
244 )
245 return
247 settings_config = app_config.get("settings_panel", {})
249 for key, config in settings_config.items():
250 if key == "theme":
251 continue
253 if config.get("enabled", True):
254 widget = self.add_setting_from_config(key, config)
255 default_value = config.get("default")
256 if default_value is not None and hasattr(widget, "set_value"): 256 ↛ 249line 256 didn't jump to line 249 because the condition on line 256 was always true
257 widget.set_value(default_value) # type: ignore[union-attr]
259 except Exception as e:
260 warn_tech(
261 code="widgets.settings_panel.load_yaml_failed",
262 message="Error loading settings from YAML",
263 error=e,
264 )
266 def add_setting_from_config(self, key: str, config: dict) -> QWidget:
267 """Add a setting based on its YAML configuration."""
268 setting_type = config.get("type", "text")
269 label = config.get("label", key)
270 description = config.get("description", "")
271 default_value = config.get("default")
273 setting_container = QFrame(self._content_widget)
274 setting_container.setObjectName(f"setting_container_{key}")
275 setting_container.setFrameShape(QFrame.Shape.NoFrame)
276 setting_container.setFrameShadow(QFrame.Shadow.Raised)
278 container_layout = QVBoxLayout(setting_container)
279 container_layout.setSpacing(8)
280 container_layout.setObjectName(f"setting_container_layout_{key}")
281 container_layout.setContentsMargins(10, 10, 10, 10)
283 if setting_type == "toggle": 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true
284 widget = self._create_toggle_widget(
285 label,
286 description,
287 bool(default_value) if default_value is not None else False,
288 key,
289 )
290 elif setting_type == "select": 290 ↛ 299line 290 didn't jump to line 299 because the condition on line 290 was always true
291 options = config.get("options", [])
292 widget = self._create_select_widget(
293 label,
294 description,
295 options,
296 str(default_value) if default_value is not None else "",
297 key,
298 )
299 elif setting_type == "slider":
300 min_val = config.get("min", 0)
301 max_val = config.get("max", 100)
302 unit = config.get("unit", "")
303 widget = self._create_slider_widget(
304 label,
305 description,
306 min_val,
307 max_val,
308 int(default_value) if default_value is not None else 0,
309 unit,
310 key,
311 )
312 elif setting_type == "checkbox":
313 widget = self._create_checkbox_widget(
314 label,
315 description,
316 bool(default_value) if default_value is not None else False,
317 key,
318 )
319 else:
320 widget = self._create_text_widget(
321 label,
322 description,
323 str(default_value) if default_value is not None else "",
324 key,
325 )
327 container_layout.addWidget(widget)
328 self._content_layout.addWidget(setting_container)
329 self._settings[key] = widget
331 return widget
333 def _create_toggle_widget(
334 self, label: str, description: str, default: bool, key: str | None = None
335 ) -> QWidget:
336 """Create a toggle widget with label and description."""
337 from ...widgets.extended.setting_widgets import SettingToggle
339 widget = SettingToggle(label, description, default)
340 if key:
341 widget.set_key(key)
342 widget.valueChanged.connect(self._on_setting_changed)
343 return widget
345 def _create_select_widget(
346 self,
347 label: str,
348 description: str,
349 options: list[str],
350 default: str,
351 key: str | None = None,
352 ) -> QWidget:
353 """Create a select widget with label and description."""
354 from ...widgets.extended.setting_widgets import SettingSelect
356 widget = SettingSelect(label, description, options, default)
357 if key: 357 ↛ 359line 357 didn't jump to line 359 because the condition on line 357 was always true
358 widget.set_key(key)
359 widget.valueChanged.connect(self._on_setting_changed)
360 return widget
362 def _create_slider_widget(
363 self,
364 label: str,
365 description: str,
366 min_val: int,
367 max_val: int,
368 default: int,
369 unit: str,
370 key: str | None = None,
371 ) -> QWidget:
372 """Create a slider widget with label and description."""
373 from ...widgets.extended.setting_widgets import SettingSlider
375 widget = SettingSlider(label, description, min_val, max_val, default, unit)
376 if key:
377 widget.set_key(key)
378 widget.valueChanged.connect(self._on_setting_changed)
379 return widget
381 def _create_checkbox_widget(
382 self, label: str, description: str, default: bool, key: str | None = None
383 ) -> QWidget:
384 """Create a checkbox widget with label and description."""
385 from ...widgets.extended.setting_widgets import SettingCheckbox
387 widget = SettingCheckbox(label, description, default)
388 if key:
389 widget.set_key(key)
390 widget.valueChanged.connect(self._on_setting_changed)
391 return widget
393 def _create_text_widget(
394 self, label: str, description: str, default: str, key: str | None = None
395 ) -> QWidget:
396 """Create a text widget with label and description."""
397 from ...widgets.extended.setting_widgets import SettingText
399 widget = SettingText(label, description, default)
400 if key:
401 widget.set_key(key)
402 widget.valueChanged.connect(self._on_setting_changed)
403 return widget
405 # ///////////////////////////////////////////////////////////////
407 def add_toggle_setting(
408 self,
409 key: str,
410 label: str,
411 default: bool = False,
412 description: str = "",
413 enabled: bool = True, # noqa: ARG002
414 ):
415 """Add a toggle setting."""
416 from ...widgets.extended.setting_widgets import SettingToggle
418 widget = SettingToggle(label, description, default)
419 widget.set_key(key)
420 widget.valueChanged.connect(self._on_setting_changed)
422 self._settings[key] = widget
423 self.add_setting_widget(widget)
424 return widget
426 def add_select_setting(
427 self,
428 key: str,
429 label: str,
430 options: list[str],
431 default: str | None = None,
432 description: str = "",
433 enabled: bool = True, # noqa: ARG002
434 ):
435 """Add a selection setting."""
436 from ...widgets.extended.setting_widgets import SettingSelect
438 widget = SettingSelect(label, description, options, default)
439 widget.set_key(key)
440 widget.valueChanged.connect(self._on_setting_changed)
442 self._settings[key] = widget
443 self.add_setting_widget(widget)
444 return widget
446 def add_slider_setting(
447 self,
448 key: str,
449 label: str,
450 min_val: int,
451 max_val: int,
452 default: int,
453 unit: str = "",
454 description: str = "",
455 enabled: bool = True, # noqa: ARG002
456 ):
457 """Add a slider setting."""
458 from ...widgets.extended.setting_widgets import SettingSlider
460 widget = SettingSlider(label, description, min_val, max_val, default, unit)
461 widget.set_key(key)
462 widget.valueChanged.connect(self._on_setting_changed)
464 self._settings[key] = widget
465 self.add_setting_widget(widget)
466 return widget
468 def add_text_setting(
469 self,
470 key: str,
471 label: str,
472 default: str = "",
473 description: str = "",
474 enabled: bool = True, # noqa: ARG002
475 ):
476 """Add a text setting."""
477 from ...widgets.extended.setting_widgets import SettingText
479 widget = SettingText(label, description, default)
480 widget.set_key(key)
481 widget.valueChanged.connect(self._on_setting_changed)
483 self._settings[key] = widget
484 self.add_setting_widget(widget)
485 return widget
487 def add_checkbox_setting(
488 self,
489 key: str,
490 label: str,
491 default: bool = False,
492 description: str = "",
493 enabled: bool = True, # noqa: ARG002
494 ):
495 """Add a checkbox setting."""
496 from ...widgets.extended.setting_widgets import SettingCheckbox
498 widget = SettingCheckbox(label, description, default)
499 widget.set_key(key)
500 widget.valueChanged.connect(self._on_setting_changed)
502 self._settings[key] = widget
503 self.add_setting_widget(widget)
504 return widget
506 def _on_setting_changed(self, key: str, value):
507 """Called when a setting changes."""
508 if not hasattr(self, "_processing_setting_change"):
509 self._processing_setting_change = False
511 if self._processing_setting_change:
512 return
514 self._processing_setting_change = True
516 try:
517 try:
518 from ...services.application.app_service import AppService
520 AppService.stage_config_value(
521 [*self._settings_storage_prefix(), key, "default"], value
522 )
523 except Exception as e:
524 warn_tech(
525 code="widgets.settings_panel.save_setting_failed",
526 message=f"Could not save setting '{key}' to YAML",
527 error=e,
528 )
530 if key == "language": 530 ↛ 545line 530 didn't jump to line 545 because the condition on line 530 was always true
531 try:
532 translation_service = get_translation_service()
533 current_lang = translation_service.get_current_language_name()
534 if current_lang != str(value): 534 ↛ 545line 534 didn't jump to line 545 because the condition on line 534 was always true
535 translation_service.change_language(str(value))
536 self._sync_theme_selector_with_settings()
537 self.languageChanged.emit()
538 except Exception as e:
539 warn_tech(
540 code="widgets.settings_panel.change_language_failed",
541 message="Could not change language",
542 error=e,
543 )
545 self.settingChanged.emit(key, value)
546 finally:
547 self._processing_setting_change = False
549 # ///////////////////////////////////////////////////////////////
551 def get_setting_value(self, key: str) -> Any:
552 """Get the value of a setting."""
553 if key in self._settings:
554 return self._settings[key].get_value()
555 return None
557 def set_setting_value(self, key: str, value: Any) -> None:
558 """Set the value of a setting."""
559 if key in self._settings:
560 self._settings[key].set_value(value)
562 def get_all_settings(self) -> dict[str, Any]:
563 """Get all settings and their values."""
564 return {key: widget.get_value() for key, widget in self._settings.items()}
566 def save_all_settings_to_yaml(self) -> None:
567 """Stage all current setting values."""
568 from ...services.application.app_service import AppService
570 for key, widget in self._settings.items():
571 try:
572 AppService.stage_config_value(
573 [*self._settings_storage_prefix(), key, "default"],
574 widget.get_value(),
575 )
576 except Exception as e:
577 warn_tech(
578 code="widgets.settings_panel.save_all_settings_failed",
579 message=f"Could not save setting '{key}' to YAML",
580 error=e,
581 )
583 def retranslate_ui(self) -> None:
584 """Apply current translations to all owned text labels."""
585 if hasattr(self, "_theme_selector") and hasattr(
586 self._theme_selector, "retranslate_ui"
587 ):
588 self._theme_selector.retranslate_ui()
589 for widget in self._settings.values():
590 if hasattr(widget, "retranslate_ui"):
591 widget.retranslate_ui()
593 def changeEvent(self, event: QEvent) -> None:
594 """Handle Qt change events."""
595 if event.type() == QEvent.Type.LanguageChange:
596 self.retranslate_ui()
597 super().changeEvent(event)
599 # ///////////////////////////////////////////////////////////////
601 def get_width(self) -> int:
602 """Get panel width."""
603 return self._width
605 def set_width(self, width: int) -> None:
606 """Set panel width."""
607 self._width = width
609 def get_theme_selector(self):
610 """Get the theme selector if available."""
611 if hasattr(self, "_theme_selector"): 611 ↛ 613line 611 didn't jump to line 613 because the condition on line 611 was always true
612 return self._theme_selector
613 return None
615 def update_all_theme_icons(self) -> None:
616 """Update theme icons for all widgets that support it."""
617 current_theme = get_settings_service().gui.THEME
618 for widget in self._widgets:
619 # New pattern: widgets exposing setTheme(theme) directly
620 setter = getattr(widget, "setTheme", None)
621 if callable(setter):
622 setter(current_theme)
623 continue
624 # Legacy pattern: widgets exposing update_theme_icon()
625 updater = getattr(widget, "update_theme_icon", None)
626 if callable(updater):
627 updater()
629 self.style().unpolish(self)
630 self.style().polish(self)
632 for child in self.findChildren(QWidget):
633 child.style().unpolish(child)
634 child.style().polish(child)
636 def _on_theme_selector_changed(self, display_value: str) -> None:
637 """Called when the theme selector value changes.
639 ``display_value`` is the human-readable label emitted by the select
640 widget (e.g. ``"Blue Gray - Dark"``). It is converted to the internal
641 ``"preset:variant"`` format before being persisted and broadcast.
642 """
643 try:
644 internal_value = self._theme_options_map.get(display_value, display_value)
645 from ...services.application.app_service import AppService
646 from ...services.settings import get_settings_service
648 get_settings_service().set_theme(internal_value)
649 AppService.stage_config_value(
650 [*self._settings_storage_prefix(), "theme", "default"],
651 internal_value,
652 )
653 self.settingChanged.emit("theme", internal_value)
654 except Exception as e:
655 warn_tech(
656 code="widgets.settings_panel.theme_selector_change_failed",
657 message="Could not handle theme selector change",
658 error=e,
659 )
661 def add_setting_widget(self, widget: QWidget) -> None:
662 """Add a new setting widget to the settings panel."""
663 setting_container = QFrame(self._content_widget)
664 setting_container.setObjectName(f"setting_container_{widget.objectName()}")
665 setting_container.setFrameShape(QFrame.Shape.NoFrame)
666 setting_container.setFrameShadow(QFrame.Shadow.Raised)
668 container_layout = QVBoxLayout(setting_container)
669 container_layout.setSpacing(8)
670 container_layout.setObjectName(
671 f"setting_container_layout_{widget.objectName()}"
672 )
673 container_layout.setContentsMargins(10, 10, 10, 10)
675 container_layout.addWidget(widget)
676 self._content_layout.addWidget(setting_container)
677 self._widgets.append(widget)
679 def add_setting_section(self, title: str = "") -> QFrame:
680 """Add a new settings section with optional title."""
681 section = QFrame(self._content_widget)
682 section.setObjectName(f"settings_section_{title.replace(' ', '_').lower()}")
683 section.setFrameShape(QFrame.Shape.NoFrame)
684 section.setFrameShadow(QFrame.Shadow.Raised)
686 section_layout = QVBoxLayout(section)
687 section_layout.setSpacing(8)
688 section_layout.setObjectName(
689 f"settings_section_layout_{title.replace(' ', '_').lower()}"
690 )
691 section_layout.setContentsMargins(10, 10, 10, 10)
693 if title:
694 title_label = QLabel(title, section)
695 title_label.setObjectName(
696 f"settings_section_title_{title.replace(' ', '_').lower()}"
697 )
698 if Fonts.SEGOE_UI_10_REG is not None:
699 title_label.setFont(Fonts.SEGOE_UI_10_REG)
700 title_label.setAlignment(
701 Qt.AlignmentFlag.AlignLeading
702 | Qt.AlignmentFlag.AlignLeft
703 | Qt.AlignmentFlag.AlignVCenter
704 )
705 section_layout.addWidget(title_label)
707 self._content_layout.addWidget(section)
708 return section
710 def scroll_to_top(self) -> None:
711 """Scroll to top of settings panel."""
712 if hasattr(self, "_scroll_area"):
713 self._scroll_area.verticalScrollBar().setValue(0)
715 def scroll_to_bottom(self) -> None:
716 """Scroll to bottom of settings panel."""
717 if hasattr(self, "_scroll_area"):
718 scrollbar = self._scroll_area.verticalScrollBar()
719 scrollbar.setValue(scrollbar.maximum())
721 def scroll_to_widget(self, widget: QWidget) -> None:
722 """Scroll to a specific widget in the settings panel."""
723 if hasattr(self, "_scroll_area") and widget:
724 widget_pos = widget.mapTo(self._content_widget, widget.rect().topLeft())
725 self._scroll_area.verticalScrollBar().setValue(widget_pos.y())
728# ///////////////////////////////////////////////////////////////
729# PUBLIC API
730# ///////////////////////////////////////////////////////////////
731__all__ = ["SettingsPanel"]