# -*- coding: utf-8 -*- # gui/components/group_box.py """Обёртка над QGroupBox с контейнером SContainer.""" from __future__ import annotations from PySide6.QtCore import Slot, Qt from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QWidget, QSizePolicy from gui.containers.s_container import SContainer from gui.styles import APP_STYLES from gui.theme_bus import theme_bus class GroupBox(SContainer): """QGroupBox с централизованным стилем и внутренним layout по умолчанию.""" _ALIGN_MAP = { "top": Qt.AlignmentFlag.AlignTop, "bottom": Qt.AlignmentFlag.AlignBottom, "left": Qt.AlignmentFlag.AlignLeft, "right": Qt.AlignmentFlag.AlignRight, "hcenter": Qt.AlignmentFlag.AlignHCenter, "vcenter": Qt.AlignmentFlag.AlignVCenter, "center": Qt.AlignmentFlag.AlignCenter, } def __init__( self, title: str = "", width_percent: int | None = None, height_percent: int | None = None, margin: int | tuple[int, int, int, int] = 0, content_margins: int | tuple[int, int, int, int] = 10, spacing: int = 8, alignment=None, style: str = "GROUP_BOX", active_style: str | None = None, is_active: bool | None = None, content_fit: bool = True, parent=None, ): super().__init__( width_percent=width_percent, height_percent=height_percent, margin=margin, style=style, active_style=active_style, is_active=is_active, content_fit=content_fit, parent=parent, ) self._theme = "dark" self._is_active = False self._base_style_key = style self._style_key_normal = None self._style_key_active = None self._group_box = QGroupBox(title) self._group_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) self._group_layout = QVBoxLayout() if isinstance(content_margins, (list, tuple)) and len(content_margins) == 4: self._group_layout.setContentsMargins(*content_margins) else: self._group_layout.setContentsMargins(content_margins, content_margins, content_margins, content_margins) self._group_layout.setSpacing(spacing) if alignment is not None: self._group_layout.setAlignment(self._normalize_alignment(alignment)) self._group_box.setLayout(self._group_layout) super().add_widget(self._group_box) if active_style is not None: self._style_key_normal = style self._style_key_active = active_style if is_active is not None: self._is_active = bool(is_active) self.style() theme_bus.theme_changed.connect(self.set_theme) def style( self, style_key: str | None = None, active_key: str | None = None, is_active: bool | None = None, ) -> None: """Короткий метод применения стиля. Можно задать ключи и активность явно.""" if style_key is not None: self._base_style_key = style_key if active_key is not None: self._style_key_normal = style_key self._style_key_active = active_key else: self._style_key_normal = None self._style_key_active = None if is_active is not None: self._is_active = bool(is_active) if self._style_key_normal is not None: active_key = self._style_key_active or self._style_key_normal key = active_key if self._is_active else self._style_key_normal themed = f"{key}_{self._theme.upper()}" if themed in APP_STYLES: key = themed self._group_box.setStyleSheet(APP_STYLES.get(key, "")) return base_key = self._base_style_key key = base_key if self._theme == "light": if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: key = f"{base_key}_LIGHT_ACTIVE" elif f"{base_key}_LIGHT" in APP_STYLES: key = f"{base_key}_LIGHT" else: if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: key = f"{base_key}_DARK_ACTIVE" elif f"{base_key}_DARK" in APP_STYLES: key = f"{base_key}_DARK" self._group_box.setStyleSheet(APP_STYLES.get(key, "")) @Slot(str) def set_theme(self, theme: str) -> None: """Внешний слот: принимает 'dark' или 'light'.""" theme = (theme or "").strip().lower() if theme not in ("dark", "light"): return if self._theme == theme: return self._theme = theme self.style() def set_title(self, title: str) -> None: self._group_box.setTitle(title) def get_title(self) -> str: return self._group_box.title() def add_widget(self, widget: QWidget) -> None: self._group_layout.addWidget(widget) def add_stretch(self, stretch: int = 1) -> None: self._group_layout.addStretch(stretch) def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: if isinstance(margin, (list, tuple)) and len(margin) == 4: self._group_layout.setContentsMargins(*margin) else: self._group_layout.setContentsMargins(margin, margin, margin, margin) def set_spacing(self, spacing: int) -> None: self._group_layout.setSpacing(spacing) def _normalize_alignment(self, alignment: str | Qt.Alignment) -> Qt.Alignment: """Преобразует строковое значение alignment в Qt.Alignment.""" if isinstance(alignment, str): key = alignment.strip().lower() mapped = self._ALIGN_MAP.get(key) if mapped is None: raise ValueError(f"Unknown alignment '{key}'. Allowed: {list(self._ALIGN_MAP.keys())}") return mapped return alignment def set_alignment(self, alignment: str | Qt.Alignment) -> None: """Устанавливает выравнивание layout (строка или Qt.Alignment).""" self._group_layout.setAlignment(self._normalize_alignment(alignment)) # --------------------------------------------------------------------------- # Module workflow notes # --------------------------------------------------------------------------- # # 1) Назначение модуля: # Обёртка над QGroupBox, встроенная в SContainer, с заголовком, # внутренним QVBoxLayout для дочерних виджетов и поддержкой стилей # APP_STYLES + theme_bus. # # 2) Зависимости модуля: # Импорты: Slot, Qt (PySide6.QtCore), # QGroupBox, QVBoxLayout, QWidget, QSizePolicy (PySide6.QtWidgets), # SContainer (gui.containers.s_container), # APP_STYLES (gui.styles), # theme_bus (gui.theme_bus) # Хост-класс / базовый класс: SContainer # Внешние библиотеки: PySide6 (обязательна) # # 3) Экспорт: # Класс GroupBox — публичный контейнерный виджет с заголовком. # Методы: style(), set_theme(), set_title(), get_title(), # add_widget(QWidget), add_stretch(), set_margins(), # set_spacing(), set_alignment(). # # 4) Состояние (поля): # _theme: str — текущая тема # _is_active: bool — признак активного состояния # _base_style_key: str — базовый ключ стиля ("GROUP_BOX") # _style_key_normal: str|None — явный нормальный стиль # _style_key_active: str|None — явный активный стиль # _group_box: QGroupBox — внутренний QGroupBox # _group_layout: QVBoxLayout — layout внутри QGroupBox # _ALIGN_MAP: dict — маппинг строковых выравниваний в Qt.Alignment # # 5) Последовательность действий и вызовов: # __init__(title, content_margins, spacing, alignment, ...) # -> super().__init__(...) # -> QGroupBox(title) -> QVBoxLayout -> setContentsMargins, setSpacing # -> setAlignment(если передано) # -> _group_box.setLayout(_group_layout) # -> super().add_widget(_group_box) — добавление QGroupBox в SContainer # -> style() -> theme_bus.theme_changed.connect(set_theme) # add_widget(widget) — ПЕРЕОПРЕДЕЛЁН: # -> _group_layout.addWidget(widget) (добавляет внутрь QGroupBox, не в SContainer) # # 6) Побочные эффекты: # - Устанавливает stylesheet на QGroupBox. # - Подключается к theme_bus.theme_changed. # - add_widget() изменяет _group_layout (не SContainer layout). # # 7) Границы ответственности: # Модуль — визуальная группировка. НЕ реализует вложенные стили # для дочерних элементов. НЕ ограничивает типы дочерних виджетов. # # 8) Обработка ошибок: # _normalize_alignment() бросает ValueError при невалидной строке # выравнивания. # # 9) Инварианты и контракты: # - _group_box всегда имеет QVBoxLayout. # - Допустимые строки выравнивания: top, bottom, left, right, # hcenter, vcenter, center. # - add_widget() GroupBox — НЕ запечатан, принимает любые QWidget. # # 10) Правило сопровождения: # Если нужна горизонтальная компоновка внутри GroupBox — # вкладывать HContainer в add_widget(), не менять _group_layout на QHBoxLayout.