Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
# -*- 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