Files
Dispatch/Dispatch_V0.1.1/gui/containers/percent_sized_widget.py
2026-04-29 08:18:54 +04:00

350 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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