# -*- coding: utf-8 -*- # gui/components/double_spin_box.py """Обёртка над QDoubleSpinBox с поддержкой централизованных APP_STYLES.""" from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy from PySide6.QtCore import Qt, Slot, Signal from gui.containers.s_container import SContainer from gui.styles import APP_STYLES from gui.theme_bus import theme_bus from error_logger import log_exception class DoubleSpinBox(SContainer): """Кастомный QDoubleSpinBox с переключением стилей по теме.""" stepped = Signal() class _StepAwareSpinBox(QDoubleSpinBox): def __init__(self, on_step, parent=None): super().__init__(parent) self._on_step = on_step def stepBy(self, steps: int) -> None: # noqa: N802 (Qt API) super().stepBy(steps) if steps and callable(self._on_step): self._on_step() def __init__( self, min_value: float = 0.0, max_value: float = 100000.0, decimals: int = 0, step: float = 1.0, suffix: str = " мм", keyboard_tracking: bool = True, width_percent: int | None = None, height_percent: int | None = None, margin: int | tuple[int, int, int, int] = 0, style: str = "COORDINATE_INPUT", active_style: str | None = None, is_active: bool | None = None, parent=None, ): super().__init__( width_percent=width_percent, height_percent=height_percent, margin=margin, style=style, active_style=active_style, is_active=is_active, 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._input = self._StepAwareSpinBox(self._emit_stepped) self._input.setRange(min_value, max_value) self._input.setDecimals(decimals) self._input.setSingleStep(step) self._input.setSuffix(suffix) self._input.setAlignment(Qt.AlignCenter) self._input.setKeyboardTracking(keyboard_tracking) self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) super().add_widget(self._input) 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._input.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._input.setStyleSheet(APP_STYLES.get(key, "")) @Slot(str) def set_theme(self, theme: str) -> None: theme = (theme or "").strip().lower() if theme not in ("dark", "light"): return if self._theme == theme: return self._theme = theme self.style() def set_value(self, value: float) -> None: self._input.setValue(value) def get_value(self) -> float: return self._input.value() def get_min_value(self) -> float: return float(self._input.minimum()) def get_max_value(self) -> float: return float(self._input.maximum()) @property def valueChanged(self): return self._input.valueChanged @property def editing_finished(self): return self._input.editingFinished @property def return_pressed(self): line_edit = self._input.lineEdit() if line_edit is None: return self._input.editingFinished return line_edit.returnPressed def set_enabled(self, enabled: bool) -> None: self._input.setEnabled(enabled) super().setEnabled(enabled) def set_min_width(self, width: int) -> None: self._input.setMinimumWidth(width) super().setMinimumWidth(width) def set_min_height(self, height: int) -> None: self._input.setMinimumHeight(height) super().setMinimumHeight(height) def set_max_width(self, width: int) -> None: self._input.setMaximumWidth(width) super().setMaximumWidth(width) def set_max_height(self, height: int) -> None: self._input.setMaximumHeight(height) super().setMaximumHeight(height) def set_fixed_size(self, width: int, height: int) -> None: self._input.setMinimumSize(width, height) self._input.setMaximumSize(width, height) super().setMinimumSize(width, height) super().setMaximumSize(width, height) def set_tooltip(self, text: str) -> None: self._input.setToolTip(text) def set_size_policy(self, horizontal, vertical) -> None: self._input.setSizePolicy(horizontal, vertical) super().setSizePolicy(horizontal, vertical) def set_range(self, min_value: float, max_value: float) -> None: self._input.setRange(min_value, max_value) def set_step(self, step: float) -> None: self._input.setSingleStep(step) def commit_pending_input(self) -> None: """Принудительно применить текст из редактора spinbox к текущему value.""" try: self._input.interpretText() except Exception as e: log_exception(__name__, "commit_pending_input", e) def _emit_stepped(self) -> None: self.stepped.emit() def add_widget(self, widget, alignment=None): raise NotImplementedError("DoubleSpinBox can contain only one QDoubleSpinBox") # --------------------------------------------------------------------------- # Module workflow notes # --------------------------------------------------------------------------- # # 1) Назначение модуля: # Обёртка над QDoubleSpinBox в SContainer для ввода числовых значений # (размеры в мм и т.п.) с суффиксом, шагом, поддержкой APP_STYLES и # автоматической сменой темы. # # 2) Зависимости модуля: # Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets), # Qt, Slot (PySide6.QtCore), # SContainer (gui.containers.s_container), # APP_STYLES (gui.styles), # theme_bus (gui.theme_bus) # Хост-класс / базовый класс: SContainer # Внешние библиотеки: PySide6 (обязательна) # # 3) Экспорт: # Класс DoubleSpinBox — публичный виджет числового ввода. # Методы: style(), set_theme(), set_value(), get_value(), # get_min_value(), get_max_value(), set_range(), set_step(), # set_enabled(), set_min/max_width/height(), set_fixed_size(), # set_tooltip(), set_size_policy(). # Свойства: valueChanged, editing_finished. # # 4) Состояние (поля): # _theme: str — текущая тема # _is_active: bool — признак активного состояния # _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT") # _style_key_normal: str|None — явный нормальный стиль # _style_key_active: str|None — явный активный стиль # _input: QDoubleSpinBox — внутренний виджет # # 5) Последовательность действий и вызовов: # __init__(min_value, max_value, decimals, step, suffix, keyboard_tracking, ...) # -> super().__init__(...) # -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep, # setSuffix, setAlignment(Center), setKeyboardTracking # -> super().add_widget(_input) # -> style() -> theme_bus.theme_changed.connect(set_theme) # style(style_key?, active_key?, is_active?) # -> разрешение ключа: явный → base_key + _DARK/_LIGHT + _ACTIVE # -> _input.setStyleSheet(APP_STYLES[key]) # # 6) Побочные эффекты: # - Устанавливает stylesheet на QDoubleSpinBox. # - Подключается к theme_bus.theme_changed. # # 7) Границы ответственности: # Модуль НЕ валидирует единицы измерения. НЕ конвертирует значения. # add_widget() заблокирован. # # 8) Обработка ошибок: # add_widget() бросает NotImplementedError. set_theme() игнорирует # невалидные значения. # # 9) Инварианты и контракты: # - Контейнер содержит ровно один QDoubleSpinBox. # - Значение в пределах [min_value, max_value]. # - keyboard_tracking определяет, испускается ли valueChanged при каждом # нажатии клавиши или только по завершении ввода. # # 10) Правило сопровождения: # Отличие от CoordinateInput: суффикс, keyboard_tracking, # decimals=0 по умолчанию. Не дублировать этот класс для целочисленного ввода — # достаточно decimals=0.