350 lines
15 KiB
Python
350 lines
15 KiB
Python
# -*- 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
|