# -*- coding: utf-8 -*- # gui/containers/percent_sized_widget.py """Базовый виджет с процентными размерами и локальной моделью style-наследования.""" from __future__ import annotations import weakref from typing import Optional from PySide6.QtCore import QEvent, QObject, QSize, QTimer, Signal, Slot from PySide6.QtWidgets import QSizePolicy, QWidget from shiboken6 import isValid from error_logger import log_exception from gui.containers._parent_resize_emitter import ParentResizeEmitter from gui.containers._widget_style_service import WidgetStyleService from gui.theme_bus import theme_bus class PercentSizedWidget(QWidget): """Базовый виджет с размерами в процентах от родителя.""" percent_size_changed = Signal(object) _EMITTER_OBJECT_NAME = "_percent_sized_widget_parent_emitter" def __init__( self, width_percent: int | float | None = None, height_percent: int | float | None = None, parent: QWidget | None = None, content_driven: bool = False, ): super().__init__(parent) self._width_percent = self._validate_percent(width_percent, "width_percent") self._height_percent = self._validate_percent(height_percent, "height_percent") self._content_driven = content_driven self.index = 0 self._target_width: Optional[int] = None self._target_height: Optional[int] = None self._prev_parent_width = 0 self._prev_parent_height = 0 self._update_pending = False self._in_update = False self._percent_values_dirty = True self._parent_emitter: Optional[ParentResizeEmitter] = None self._parent_emitter_owner: Optional[QWidget] = None self._style_service: Optional[WidgetStyleService] = None self._style_parent: Optional[PercentSizedWidget] = None self._style_children: weakref.WeakSet[PercentSizedWidget] = weakref.WeakSet() self._setup_size_policy() self._sync_parent_attachment() self.schedule_percent_update() self._auto_add_to_parent() self._sync_parent_attachment() def changeEvent(self, event: QEvent) -> None: self._sync_parent_attachment() super().changeEvent(event) def showEvent(self, event: QEvent) -> None: super().showEvent(event) self._sync_parent_attachment() self._rebind_style_parent() self._percent_values_dirty = True self.schedule_percent_update() def _reattach_to_new_parent(self) -> None: self._sync_parent_attachment() def _detach_from_current_parent(self) -> None: if not self._parent_emitter: return try: self._parent_emitter.parent_resized.disconnect(self._on_parent_resized) except RuntimeError as e: log_exception(__name__, "_detach_from_current_parent", e) try: self._parent_emitter.parent_rebuild_finished.disconnect(self._on_parent_rebuild_finished) except RuntimeError as e: log_exception(__name__, "_detach_from_current_parent.rebuild_finished", e) self._parent_emitter = None self._parent_emitter_owner = None def _attach_to_parent(self, parent: QWidget) -> None: emitter = self._find_parent_emitter(parent) if not emitter: emitter = ParentResizeEmitter(parent) emitter.setObjectName(self._EMITTER_OBJECT_NAME) emitter.parent_resized.connect(self._on_parent_resized) emitter.parent_rebuild_finished.connect(self._on_parent_rebuild_finished) self._parent_emitter = emitter self._parent_emitter_owner = parent def _sync_parent_attachment(self) -> None: if not isValid(self): return try: parent = self.parentWidget() except RuntimeError as e: log_exception(__name__, "_sync_parent_attachment", e) return if self._parent_emitter_owner is not None and not isValid(self._parent_emitter_owner): self._parent_emitter_owner = None current_parent = parent if isinstance(parent, QWidget) else None if current_parent is self._parent_emitter_owner: return self._detach_from_current_parent() if current_parent is None: return self._attach_to_parent(current_parent) self._rebind_style_parent() self._percent_values_dirty = True self.schedule_percent_update() def _find_parent_emitter(self, parent: QWidget) -> ParentResizeEmitter | None: for child in parent.children(): if not isinstance(child, QObject): continue if not isinstance(child, ParentResizeEmitter): continue if child.objectName() == self._EMITTER_OBJECT_NAME: return child return None def on_children_rebuild_finished(self, slot) -> None: """Подписать ``slot`` на фазу 2 каскада percent-sized для детей этого виджета. Сигнал ``parent_rebuild_finished`` per-parent эмиттера срабатывает после того, как дети-PercentSizedWidget ``self`` обработали ``parent_resized`` (фаза 1) и Qt активировал layout. Это единственная точка, в которой виджет-родитель видит стабильную геометрию своих процентных детей. Эмиттер создаётся eagerly, чтобы подписка работала и в момент, когда у ``self`` ещё нет процентных детей (инициализационный сценарий). """ emitter = self._find_parent_emitter(self) if emitter is None: emitter = ParentResizeEmitter(self) emitter.setObjectName(self._EMITTER_OBJECT_NAME) emitter.parent_rebuild_finished.connect(slot) def _auto_add_to_parent(self) -> None: try: parent = self.parentWidget() except RuntimeError as e: log_exception(__name__, "_auto_add_to_parent", e) return if not isinstance(parent, QWidget) or not getattr(parent, "_auto_add_children", False): return if hasattr(parent, "add_widget"): try: parent.add_widget(self) return except Exception as e: log_exception(__name__, "_auto_add_to_parent.add_widget", e) layout = parent.layout() if layout is not None and layout.indexOf(self) == -1: layout.addWidget(self) @staticmethod def _validate_percent(percent: int | float | None, param_name: str) -> float | None: if percent is None: return None if not isinstance(percent, (int, float)): raise TypeError(f"{param_name} должен быть числом (int/float) или None") value = round(float(percent), 2) if not 0.01 <= value <= 100.0: raise ValueError(f"{param_name} должен быть в диапазоне от 0.01 до 100.00") return value def _setup_size_policy(self) -> None: fallback = QSizePolicy.Policy.Preferred if self._content_driven else QSizePolicy.Policy.Expanding horizontal = fallback if self._width_percent is None else QSizePolicy.Policy.Fixed vertical = fallback if self._height_percent is None else QSizePolicy.Policy.Fixed self.setSizePolicy(horizontal, vertical) def sizeHint(self) -> QSize: base = super().sizeHint() width = self._target_width if self._target_width is not None else base.width() height = self._target_height if self._target_height is not None else base.height() return QSize(max(0, width), max(0, height)) def minimumSizeHint(self) -> QSize: return QSize(0, 0) def set_percent_sizes( self, width_percent: int | float | None = None, height_percent: int | float | None = None, ) -> None: self._width_percent = self._validate_percent(width_percent, "width_percent") self._height_percent = self._validate_percent(height_percent, "height_percent") self._percent_values_dirty = True self._setup_size_policy() self._update_percent_size() def set_enabled(self, enabled: bool) -> None: self.setEnabled(enabled) def is_enabled(self) -> bool: return self.isEnabled() def set_visible(self, visible: bool) -> None: self.setVisible(visible) def set_min_width(self, width: int) -> None: self.setMinimumWidth(width) def set_min_height(self, height: int) -> None: self.setMinimumHeight(height) def set_max_width(self, width: int) -> None: self.setMaximumWidth(width) def set_max_height(self, height: int) -> None: self.setMaximumHeight(height) def set_tooltip(self, text: str) -> None: self.setToolTip(text) def set_size_policy(self, horizontal, vertical) -> None: self.setSizePolicy(horizontal, vertical) def get_target_sizes(self) -> tuple[int | None, int | None]: return self._target_width, self._target_height def get_percent_sizes(self) -> tuple[float | None, float | None]: return self._width_percent, self._height_percent def has_percent_sizing(self) -> bool: return self._width_percent is not None or self._height_percent is not None def set_width_pixels(self, width_pixels: int) -> None: try: parent = self.parentWidget() except RuntimeError as e: log_exception(__name__, "set_width_pixels", e) return if not isinstance(parent, QWidget): return parent_width = max(1, parent.contentsRect().width()) self.set_percent_sizes(width_percent=round(max(0.01, min(100.0, width_pixels * 100.0 / parent_width)), 2)) def set_fixed_size(self, width: int, height: int) -> None: self.setMinimumSize(width, height) self.setMaximumSize(width, height) @property def width_percent(self) -> float | None: return self._width_percent @width_percent.setter def width_percent(self, value: int | float | None) -> None: self.set_percent_sizes(width_percent=value) @property def height_percent(self) -> float | None: return self._height_percent @height_percent.setter def height_percent(self, value: int | float | None) -> None: self.set_percent_sizes(height_percent=value) def _init_stylable(self) -> None: self._style_service = WidgetStyleService(self) theme_bus.theme_changed.connect(self.set_theme) self._rebind_style_parent() def _apply_style(self, style_key=None, active_key=None, is_active=None) -> None: if self._style_service and self._style_service.apply(style_key=style_key, active_key=active_key, is_active=is_active): self._sync_descendant_inherited_styles() def style(self, style_key=None, active_key=None, is_active=None) -> None: self._apply_style(style_key=style_key, active_key=active_key, is_active=is_active) @Slot(str) def set_theme(self, theme: str) -> None: if self._style_service: self._style_service.handle_theme_changed(theme) def _sync_inherited_style(self) -> bool: if not self._style_service: return False inherited_style = None if self._style_parent and self._style_parent._style_service: inherited_style = self._style_parent._style_service.effective_style_key return self._style_service.set_inherited_style(inherited_style) def _find_style_parent(self) -> PercentSizedWidget | None: parent = self.parentWidget() while isinstance(parent, QWidget): if isinstance(parent, PercentSizedWidget) and parent._style_service: return parent parent = parent.parentWidget() return None def _sync_descendant_inherited_styles(self) -> None: if not self._style_service: return inherited_style = self._style_service.effective_style_key for child in tuple(self._style_children): child._inherit_style_from_parent(inherited_style) def _inherit_style_from_parent(self, inherited_style: str | None) -> bool: if not self._style_service: return False if self._style_service.set_inherited_style(inherited_style): self._sync_descendant_inherited_styles() return True return False def _rebind_style_parent(self) -> None: if not self._style_service: return new_style_parent = self._find_style_parent() if new_style_parent is not self._style_parent: if self._style_parent is not None: self._style_parent._style_children.discard(self) self._style_parent = new_style_parent if self._style_parent is not None: self._style_parent._style_children.add(self) if self._sync_inherited_style(): self._sync_descendant_inherited_styles() def _on_parent_resized(self) -> None: self._update_percent_size() def _on_parent_rebuild_finished(self) -> None: # Фаза 2: родитель завершил собственное перестроение и геометрия стабильна. # Форсируем пересчёт даже если parent_width/parent_height численно совпали # с промежуточным значением, зафиксированным в фазе 1. self._percent_values_dirty = True self._update_percent_size() def schedule_percent_update(self) -> None: if self._update_pending: return self._update_pending = True QTimer.singleShot(0, self._update_percent_size) def _trigger_children_update(self) -> None: for child in self.children(): if isinstance(child, PercentSizedWidget) and child.parentWidget() is self: child._update_percent_size() def _update_percent_size(self) -> None: if self._in_update: return self._in_update = True self._update_pending = False try: self._sync_parent_attachment() try: parent = self.parentWidget() except RuntimeError as e: log_exception(__name__, "_update_percent_size", e) return if not isinstance(parent, QWidget): return rect = parent.contentsRect() parent_width, parent_height = max(0, rect.width()), max(0, rect.height()) changed = parent_width != self._prev_parent_width or parent_height != self._prev_parent_height self._prev_parent_width = parent_width self._prev_parent_height = parent_height if not changed and not self._percent_values_dirty: return self._percent_values_dirty = False self._target_width = round(parent_width * self._width_percent / 100.0) if self._width_percent is not None else None self._target_height = round(parent_height * self._height_percent / 100.0) if self._height_percent is not None else None self.updateGeometry() layout = parent.layout() if layout: layout.activate() self._trigger_children_update() finally: self._in_update = False