Add Dispatch_V0.1.1
This commit is contained in:
38
Dispatch_V0.1.1/gui/containers/__init__.py
Normal file
38
Dispatch_V0.1.1/gui/containers/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/__init__.py
|
||||
|
||||
from .v_container import VContainer
|
||||
from .h_container import HContainer
|
||||
from .s_container import SContainer
|
||||
from .grid_container import GridContainer
|
||||
from .stack_container import StackContainer
|
||||
from .scroll_container import ScrollContainer
|
||||
|
||||
__all__ = [
|
||||
'VContainer',
|
||||
'HContainer',
|
||||
'SContainer',
|
||||
'GridContainer',
|
||||
'StackContainer',
|
||||
'ScrollContainer',
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes (compact)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Пакетный __init__.py для gui/containers. Реэкспортирует все публичные
|
||||
# контейнерные классы для удобного импорта:
|
||||
# ``from gui.containers import VContainer, HContainer, ...``
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Реимпорт из: v_container, h_container, s_container, grid_container,
|
||||
# stack_container, scroll_container, stylable_mixin.
|
||||
#
|
||||
# 3) Экспорт (__all__):
|
||||
# VContainer, HContainer, SContainer, GridContainer,
|
||||
# StackContainer, ScrollContainer, StylableMixin.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
96
Dispatch_V0.1.1/gui/containers/_parent_resize_emitter.py
Normal file
96
Dispatch_V0.1.1/gui/containers/_parent_resize_emitter.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/_parent_resize_emitter.py
|
||||
"""Эмиттер resize-событий родительского виджета (один экземпляр на parent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, QEvent, QSize, QTimer, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from shiboken6 import isValid
|
||||
|
||||
|
||||
class ParentResizeEmitter(QObject):
|
||||
"""
|
||||
Перехватывает Resize-события родителя через eventFilter.
|
||||
Один экземпляр на каждого родителя, разделяется всеми потомками.
|
||||
|
||||
Эмитирует две фазы:
|
||||
1) parent_resized — сразу после coalesced singleShot(0);
|
||||
2) parent_rebuild_finished — отдельным singleShot(0) после фазы 1.
|
||||
Между фазами Qt успевает обработать layout.activate() подписчиков фазы 1,
|
||||
поэтому подписчики фазы 2 видят финальную, стабильную геометрию родителя.
|
||||
"""
|
||||
parent_resized = Signal()
|
||||
parent_rebuild_finished = Signal()
|
||||
|
||||
def __init__(self, parent_widget: QWidget):
|
||||
super().__init__(parent_widget)
|
||||
self._parent_widget: QWidget | None = parent_widget
|
||||
self._last_size = parent_widget.size()
|
||||
self._emit_pending = False
|
||||
self._finish_pending = False
|
||||
# destroyed.connect(self._on_parent_destroyed) намеренно не используем:
|
||||
# порядок разрушения Python-обёрток эмиттера и parent_widget не определён,
|
||||
# и Qt может вызвать слот на полу-разрушенном объекте, выдавая
|
||||
# "Slot 'ParentResizeEmitter::' not found". Все queued-обработчики
|
||||
# (_emit_parent_resized, _emit_rebuild_finished) защищены проверкой
|
||||
# _is_valid_qobject(_parent_widget), поэтому ссылка на мёртвый
|
||||
# parent безопасна — событий просто не будет.
|
||||
parent_widget.installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
# Защита от полу-разрушенного состояния: Qt может вызвать eventFilter
|
||||
# на эмиттере, чей Python-объект уже частично очищен (атрибуты удалены),
|
||||
# но C++ instance ещё жив и обрабатывает события из очереди.
|
||||
parent_widget = getattr(self, "_parent_widget", None)
|
||||
if not self._is_valid_qobject(parent_widget):
|
||||
return False
|
||||
if watched is not parent_widget:
|
||||
return False
|
||||
if not self._is_valid_qobject(watched):
|
||||
return False
|
||||
try:
|
||||
current_size = watched.size()
|
||||
except RuntimeError:
|
||||
# Underlying C++ QWidget was deleted; shiboken raises RuntimeError on method calls.
|
||||
return False
|
||||
last_size = getattr(self, "_last_size", None)
|
||||
if current_size != last_size:
|
||||
self._last_size = current_size
|
||||
self._schedule_parent_resized()
|
||||
return False
|
||||
|
||||
def _on_parent_destroyed(self) -> None:
|
||||
self._parent_widget = None
|
||||
self._emit_pending = False
|
||||
self._finish_pending = False
|
||||
self._last_size = QSize()
|
||||
|
||||
def _schedule_parent_resized(self) -> None:
|
||||
if self._emit_pending:
|
||||
return
|
||||
self._emit_pending = True
|
||||
QTimer.singleShot(0, self._emit_parent_resized)
|
||||
|
||||
def _emit_parent_resized(self) -> None:
|
||||
self._emit_pending = False
|
||||
if not self._is_valid_qobject(self._parent_widget):
|
||||
return
|
||||
self.parent_resized.emit()
|
||||
self._schedule_rebuild_finished()
|
||||
|
||||
def _schedule_rebuild_finished(self) -> None:
|
||||
if self._finish_pending:
|
||||
return
|
||||
self._finish_pending = True
|
||||
QTimer.singleShot(0, self._emit_rebuild_finished)
|
||||
|
||||
def _emit_rebuild_finished(self) -> None:
|
||||
self._finish_pending = False
|
||||
if not self._is_valid_qobject(self._parent_widget):
|
||||
return
|
||||
self.parent_rebuild_finished.emit()
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_qobject(obj: object) -> bool:
|
||||
return obj is not None and isValid(obj)
|
||||
132
Dispatch_V0.1.1/gui/containers/_widget_style_service.py
Normal file
132
Dispatch_V0.1.1/gui/containers/_widget_style_service.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/_widget_style_service.py
|
||||
"""Сервис локальной стилизации host-виджета без протекания style в subtree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from gui.styles import APP_STYLES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
_SELECTOR_BLOCK_RE = re.compile(r"([^{}]+)\{")
|
||||
|
||||
|
||||
class WidgetStyleService:
|
||||
"""Хранит explicit/inherited/effective style и применяет QSS только к host."""
|
||||
|
||||
def __init__(self, host: QWidget) -> None:
|
||||
self._host = host
|
||||
self._theme = "dark" if host.palette().window().color().lightness() < 128 else "light"
|
||||
self._is_active = False
|
||||
self._explicit_style_key: Optional[str] = None
|
||||
self._explicit_active_style_key: Optional[str] = None
|
||||
self._inherited_style_key: Optional[str] = None
|
||||
self._last_style_role = ""
|
||||
self._last_stylesheet = ""
|
||||
self._last_styled_background = False
|
||||
|
||||
@property
|
||||
def explicit_style_key(self) -> Optional[str]:
|
||||
return self._explicit_style_key
|
||||
|
||||
@property
|
||||
def inherited_style_key(self) -> Optional[str]:
|
||||
return self._inherited_style_key
|
||||
|
||||
@property
|
||||
def effective_style_key(self) -> Optional[str]:
|
||||
return self._explicit_style_key or self._inherited_style_key
|
||||
|
||||
def has_explicit_style(self) -> bool:
|
||||
return self._explicit_style_key is not None
|
||||
|
||||
def apply(
|
||||
self,
|
||||
style_key: Optional[str] = None,
|
||||
active_key: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> bool:
|
||||
previous_effective = self.effective_style_key
|
||||
previous_render = self._current_render_key()
|
||||
if style_key is not None:
|
||||
self._explicit_style_key = style_key
|
||||
self._explicit_active_style_key = style_key if active_key is None else active_key
|
||||
elif active_key is not None:
|
||||
self._explicit_active_style_key = active_key
|
||||
if is_active is not None:
|
||||
self._is_active = bool(is_active)
|
||||
effective_changed = previous_effective != self.effective_style_key
|
||||
if effective_changed or previous_render != self._current_render_key():
|
||||
self._refresh_host()
|
||||
return effective_changed
|
||||
|
||||
def set_inherited_style(self, style_key: Optional[str]) -> bool:
|
||||
previous_effective = self.effective_style_key
|
||||
previous_render = self._current_render_key()
|
||||
self._inherited_style_key = style_key
|
||||
effective_changed = previous_effective != self.effective_style_key
|
||||
if effective_changed or previous_render != self._current_render_key():
|
||||
self._refresh_host()
|
||||
return effective_changed
|
||||
|
||||
def handle_theme_changed(self, theme: str) -> None:
|
||||
theme = (theme or "").strip().lower()
|
||||
if theme in {"dark", "light"} and theme != self._theme:
|
||||
self._theme = theme
|
||||
self._refresh_host()
|
||||
|
||||
def _refresh_host(self) -> None:
|
||||
style_role = self.effective_style_key or ""
|
||||
render_key = self._current_render_key()
|
||||
resolved_key = self._resolve_theme_key(render_key)
|
||||
css = APP_STYLES.get(resolved_key, "") if resolved_key else ""
|
||||
stylesheet = self._scope_css_to_host(css, style_role) if css and style_role else ""
|
||||
styled_background = bool(css)
|
||||
if style_role != self._last_style_role:
|
||||
self._host.setProperty("style_role", style_role)
|
||||
self._last_style_role = style_role
|
||||
if stylesheet != self._last_stylesheet:
|
||||
self._host.setStyleSheet(stylesheet)
|
||||
self._last_stylesheet = stylesheet
|
||||
if styled_background != self._last_styled_background:
|
||||
self._host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, styled_background)
|
||||
self._last_styled_background = styled_background
|
||||
|
||||
def _resolve_theme_key(self, style_key: Optional[str]) -> Optional[str]:
|
||||
if not style_key:
|
||||
return None
|
||||
themed_key = f"{style_key}_{self._theme.upper()}"
|
||||
if themed_key in APP_STYLES:
|
||||
return themed_key
|
||||
return style_key if style_key in APP_STYLES else None
|
||||
|
||||
def _current_render_key(self) -> Optional[str]:
|
||||
return self._explicit_active_style_key if self._is_active else self.effective_style_key
|
||||
|
||||
def _scope_css_to_host(self, css: str, style_role: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
selectors = []
|
||||
for raw_selector in match.group(1).split(","):
|
||||
selector = raw_selector.strip()
|
||||
selectors.append(self._scope_selector(selector, style_role))
|
||||
return ", ".join(selectors) + " {"
|
||||
|
||||
return _SELECTOR_BLOCK_RE.sub(replace, css)
|
||||
|
||||
@staticmethod
|
||||
def _scope_selector(selector: str, style_role: str) -> str:
|
||||
if not selector or any(token in selector for token in (" ", ">", "+", "~")):
|
||||
return selector
|
||||
if 'style_role="' in selector:
|
||||
return selector
|
||||
pseudo_index = selector.find(":")
|
||||
base = selector if pseudo_index < 0 else selector[:pseudo_index]
|
||||
suffix = "" if pseudo_index < 0 else selector[pseudo_index:]
|
||||
return f'{base}[style_role="{style_role}"]{suffix}'
|
||||
145
Dispatch_V0.1.1/gui/containers/content_host.py
Normal file
145
Dispatch_V0.1.1/gui/containers/content_host.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/content_host.py
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QSizePolicy
|
||||
from .percent_sized_widget import PercentSizedWidget
|
||||
|
||||
|
||||
class ContentHost(PercentSizedWidget):
|
||||
"""Внутренний хост контента с процентными размерами и базовым layout."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width_percent: int | None = None,
|
||||
height_percent: int | None = None,
|
||||
orientation: str = "v",
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
spacing: int = 0,
|
||||
parent: QWidget | None = None,
|
||||
):
|
||||
super().__init__(width_percent, height_percent, parent)
|
||||
|
||||
if orientation == "h":
|
||||
self._layout = QHBoxLayout(self)
|
||||
else:
|
||||
self._layout = QVBoxLayout(self)
|
||||
|
||||
self._layout.setSpacing(spacing)
|
||||
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
self._layout.setContentsMargins(*margin)
|
||||
else:
|
||||
self._layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
# Контент-хост по умолчанию растягивается внутри контейнера
|
||||
self.set_content_fit(True)
|
||||
|
||||
def set_content_fit(self, expand: bool) -> None:
|
||||
"""Управление растягиванием контента внутри контейнера."""
|
||||
if expand:
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
|
||||
def _auto_add_to_parent(self) -> None:
|
||||
"""Контент-хост добавляется вручную контейнером."""
|
||||
return
|
||||
|
||||
def add_widget(self, widget: QWidget, alignment=None) -> None:
|
||||
"""Добавляет виджет в layout контейнера."""
|
||||
self._layout.addWidget(widget)
|
||||
self._notify_children_layout_changed()
|
||||
|
||||
def insert_widget(self, index: int, widget: QWidget) -> None:
|
||||
"""Вставляет виджет в layout контейнера по индексу."""
|
||||
self._layout.insertWidget(index, widget)
|
||||
self._notify_children_layout_changed()
|
||||
|
||||
def remove_widget(self, widget: QWidget) -> None:
|
||||
"""Удаляет виджет из layout контейнера."""
|
||||
self._layout.removeWidget(widget)
|
||||
self._notify_children_layout_changed()
|
||||
|
||||
def add_widget_with_stretch(self, widget: QWidget, stretch: int, alignment=None) -> None:
|
||||
"""Добавляет виджет с stretch в layout контейнера."""
|
||||
self._layout.addWidget(widget, stretch)
|
||||
self._notify_children_layout_changed()
|
||||
|
||||
def add_stretch(self, stretch: int = 1) -> None:
|
||||
self._layout.addStretch(stretch)
|
||||
|
||||
def get_layout(self):
|
||||
return self._layout
|
||||
|
||||
def _notify_children_layout_changed(self) -> None:
|
||||
"""Канал «состав детей»: после изменения списка детей host'а просим
|
||||
каждого percent-sized потомка перепланировать свой пересчёт.
|
||||
Коалесцирование обеспечивается флагом _update_pending в каждом потомке —
|
||||
несколько add_widget(...) подряд дают только один singleShot(0) на потомка.
|
||||
"""
|
||||
for index in range(self._layout.count()):
|
||||
item = self._layout.itemAt(index)
|
||||
if item is None:
|
||||
continue
|
||||
child_widget = item.widget()
|
||||
if isinstance(child_widget, PercentSizedWidget):
|
||||
child_widget.schedule_percent_update()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Внутренний хост контента — промежуточный виджет, который размещается
|
||||
# внутри контейнеров (SContainer, GridContainer, StackContainer и пр.)
|
||||
# и содержит реальные дочерние виджеты. Поддерживает процентные размеры,
|
||||
# вертикальную/горизонтальную ориентацию layout'а, отступы и spacing.
|
||||
# Является деталью реализации контейнеров, а не частью публичного API.
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: Qt, QVBoxLayout, QHBoxLayout, QWidget, QSizePolicy (PySide6)
|
||||
# Хост/базовый класс: PercentSizedWidget (percent_sized_widget.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс ContentHost — внутренний хост контента с layout.
|
||||
# Методы: set_content_fit(), add_widget(), add_widget_with_stretch(),
|
||||
# add_stretch(), get_layout()
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# _layout : QVBoxLayout | QHBoxLayout — layout для размещения потомков,
|
||||
# выбирается по параметру orientation ("v"/"h").
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(width_percent, height_percent)
|
||||
# -> создание QVBoxLayout/QHBoxLayout
|
||||
# -> setSpacing() -> setContentsMargins()
|
||||
# -> set_content_fit(True) — SizePolicy = Expanding по умолчанию
|
||||
# add_widget(widget) -> _layout.addWidget(widget)
|
||||
# add_widget_with_stretch(widget, stretch) -> _layout.addWidget(widget, stretch)
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# Устанавливает SizePolicy на self при вызове set_content_fit().
|
||||
# _auto_add_to_parent() переопределён как no-op — ContentHost добавляется
|
||||
# контейнером вручную, а не через механизм авто-добавления PercentSizedWidget.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ управляет собственными процентами — это делает PercentSizedWidget.
|
||||
# НЕ стилизуется (не наследует StylableMixin).
|
||||
# НЕ дублирует логику контейнера; лишь предоставляет layout.
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# Нет явной обработки; некорректные margin/spacing молча приведут
|
||||
# к ошибке Qt. Если margin — не tuple(4), воспринимается как int.
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - orientation ∈ {"v", "h"}, иначе умолчание — вертикальный.
|
||||
# - margin: int или tuple(4). Иной тип вызовет ошибку setContentsMargins.
|
||||
# - _auto_add_to_parent всегда возвращает None.
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# Не расширять публичный интерфейс ContentHost — он должен оставаться
|
||||
# тонкой обёрткой. Новые фичи (стилизация, alignment) добавлять
|
||||
# в контейнеры, использующие ContentHost, а не сюда.
|
||||
333
Dispatch_V0.1.1/gui/containers/grid_container.py
Normal file
333
Dispatch_V0.1.1/gui/containers/grid_container.py
Normal file
@@ -0,0 +1,333 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/grid_container.py
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QGridLayout, QLayout, QWidget, QSizePolicy
|
||||
from .percent_sized_widget import PercentSizedWidget
|
||||
from .content_host import ContentHost
|
||||
|
||||
|
||||
class GridContainer(PercentSizedWidget):
|
||||
"""Сеточный контейнер с размерами в процентах от родителя."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width_percent: int | float | None = None,
|
||||
height_percent: int | float | None = None,
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
content_margins: int | tuple[int, int, int, int] | None = None,
|
||||
spacing: int | None = None,
|
||||
horizontal_spacing: int = 0,
|
||||
vertical_spacing: int = 0,
|
||||
content_width_percent: int | None = None,
|
||||
content_height_percent: int | None = None,
|
||||
content_width: int | None = None,
|
||||
content_height: int | None = None,
|
||||
content_fit: bool = True,
|
||||
row_percentages: list[int | float] | None = None,
|
||||
col_percentages: list[int | float] | None = None,
|
||||
row_stretches: list[int] | None = None,
|
||||
col_stretches: list[int] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
style: str | None = None,
|
||||
active_style: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
):
|
||||
super().__init__(width_percent, height_percent, parent)
|
||||
self._init_stylable()
|
||||
|
||||
self._outer_layout = QGridLayout(self)
|
||||
self._outer_layout.setSpacing(0)
|
||||
|
||||
applied_margins = content_margins if content_margins is not None else margin
|
||||
if isinstance(applied_margins, (list, tuple)) and len(applied_margins) == 4:
|
||||
self._outer_layout.setContentsMargins(*applied_margins)
|
||||
else:
|
||||
self._outer_layout.setContentsMargins(applied_margins, applied_margins, applied_margins, applied_margins)
|
||||
|
||||
self._content_host = ContentHost(
|
||||
width_percent=content_width_percent,
|
||||
height_percent=content_height_percent,
|
||||
orientation="v",
|
||||
margin=0,
|
||||
spacing=0,
|
||||
parent=self,
|
||||
)
|
||||
self._content_host.set_content_fit(content_fit)
|
||||
|
||||
if content_width is not None or content_height is not None:
|
||||
w = content_width if content_width is not None else self._content_host.sizeHint().width()
|
||||
h = content_height if content_height is not None else self._content_host.sizeHint().height()
|
||||
self._content_host.set_fixed_size(w, h)
|
||||
|
||||
self._grid_widget = QWidget(self._content_host)
|
||||
self._layout = QGridLayout(self._grid_widget)
|
||||
|
||||
if spacing is not None:
|
||||
self._layout.setHorizontalSpacing(spacing)
|
||||
self._layout.setVerticalSpacing(spacing)
|
||||
else:
|
||||
self._layout.setHorizontalSpacing(horizontal_spacing)
|
||||
self._layout.setVerticalSpacing(vertical_spacing)
|
||||
|
||||
self._content_host.add_widget(self._grid_widget)
|
||||
|
||||
self._outer_layout.addWidget(self._content_host, 0, 0)
|
||||
self._outer_layout.setRowStretch(0, 1)
|
||||
self._outer_layout.setColumnStretch(0, 1)
|
||||
|
||||
self._row_percentages = []
|
||||
self._col_percentages = []
|
||||
|
||||
if row_percentages is not None and col_percentages is not None:
|
||||
self.set_cell_percentages(row_percentages, col_percentages)
|
||||
|
||||
if row_stretches is not None:
|
||||
self.set_row_stretches(row_stretches)
|
||||
if col_stretches is not None:
|
||||
self.set_col_stretches(col_stretches)
|
||||
|
||||
if style is not None or active_style is not None or is_active is not None:
|
||||
self._apply_style(style_key=style, active_key=active_style, is_active=is_active)
|
||||
|
||||
|
||||
def set_cell_percentages(self, row_percentages: list[int | float], col_percentages: list[int | float]):
|
||||
"""Устанавливает процентное соотношение строк и столбцов."""
|
||||
if not row_percentages or not col_percentages:
|
||||
raise ValueError("Не указано процентное соотношение ячеек сетки")
|
||||
|
||||
self._row_percentages = self.normalize_percentages(row_percentages)
|
||||
self._col_percentages = self.normalize_percentages(col_percentages)
|
||||
|
||||
for row, percent in enumerate(self._row_percentages):
|
||||
self._layout.setRowStretch(row, int(percent))
|
||||
|
||||
for col, percent in enumerate(self._col_percentages):
|
||||
self._layout.setColumnStretch(col, int(percent))
|
||||
|
||||
def normalize_percentages(self, percentages: list[int | float]) -> list[float]:
|
||||
"""Нормализует список процентов до суммы 100."""
|
||||
total = sum(percentages)
|
||||
if total == 0:
|
||||
return [100 / len(percentages)] * len(percentages)
|
||||
elif total != 100:
|
||||
return [p / total * 100 for p in percentages]
|
||||
return percentages.copy()
|
||||
|
||||
def add_widget(self, widget: QWidget, row: int, col: int,
|
||||
row_span: int = 1, col_span: int = 1,
|
||||
alignment: str | None = None) -> None:
|
||||
|
||||
if row + row_span - 1 >= len(self._row_percentages) or col + col_span - 1 >= len(self._col_percentages):
|
||||
raise IndexError(f"Ячейка ({row}, {col}) с span ({row_span}, {col_span}) вне диапазона сетки")
|
||||
|
||||
self._layout.addWidget(widget, row, col, row_span, col_span)
|
||||
if isinstance(widget, PercentSizedWidget):
|
||||
widget.schedule_percent_update()
|
||||
|
||||
def set_spacing(self, horizontal: int, vertical: int) -> None:
|
||||
"""
|
||||
Устанавливает раздельные отступы между ячейками по горизонтали и вертикали.
|
||||
|
||||
Args:
|
||||
horizontal: Отступ между колонками в пикселях.
|
||||
vertical: Отступ между строками в пикселях.
|
||||
"""
|
||||
self._layout.setHorizontalSpacing(horizontal)
|
||||
self._layout.setVerticalSpacing(vertical)
|
||||
|
||||
def get_layout(self) -> QLayout:
|
||||
return self._outer_layout
|
||||
|
||||
def set_margins(self, margin: int | tuple[int, int, int, int]) -> None:
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
self._outer_layout.setContentsMargins(*margin)
|
||||
else:
|
||||
self._outer_layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def set_alignment(self, alignment: str) -> None:
|
||||
raise NotImplementedError("Qt alignment for containers is disabled; use content springs.")
|
||||
|
||||
def set_column_minimum_width(self, col: int, width: int) -> None:
|
||||
"""
|
||||
Устанавливает минимальную ширину колонки.
|
||||
|
||||
Args:
|
||||
col: Индекс колонки (начиная с 0).
|
||||
width: Минимальная ширина в пикселях.
|
||||
"""
|
||||
if col < 0:
|
||||
raise ValueError(f"Индекс колонки не может быть отрицательным: {col}")
|
||||
self._layout.setColumnMinimumWidth(col, width)
|
||||
|
||||
def set_column_min_widths(self, widths: list[int]) -> None:
|
||||
for col, width in enumerate(widths):
|
||||
self.set_column_minimum_width(col, width)
|
||||
|
||||
def set_row_stretches(self, stretches: list[int]) -> None:
|
||||
for row, stretch in enumerate(stretches):
|
||||
self._layout.setRowStretch(row, int(stretch))
|
||||
|
||||
def set_col_stretches(self, stretches: list[int]) -> None:
|
||||
for col, stretch in enumerate(stretches):
|
||||
self._layout.setColumnStretch(col, int(stretch))
|
||||
|
||||
def set_row_minimum_height(self, row: int, height: int) -> None:
|
||||
"""
|
||||
Устанавливает минимальную высоту строки.
|
||||
|
||||
Args:
|
||||
row: Индекс строки (начиная с 0).
|
||||
height: Минимальная высота в пикселях.
|
||||
"""
|
||||
if row < 0:
|
||||
raise ValueError(f"Индекс строки не может быть отрицательным: {row}")
|
||||
self._layout.setRowMinimumHeight(row, height)
|
||||
|
||||
def set_row_min_heights(self, heights: list[int]) -> None:
|
||||
for row, height in enumerate(heights):
|
||||
self.set_row_minimum_height(row, height)
|
||||
|
||||
def get_available_size_for_content(self) -> tuple[int, int]:
|
||||
"""Полезная внутренняя область (без учёта margin)."""
|
||||
margins = self._outer_layout.contentsMargins()
|
||||
w = self.width() - margins.left() - margins.right()
|
||||
h = self.height() - margins.top() - margins.bottom()
|
||||
return max(0, w), max(0, h)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Примеры использования GridContainer
|
||||
# ============================================================================
|
||||
#
|
||||
# Базовый пример: форма с двумя колонками (labels и inputs)
|
||||
# ----------------------------------------------------------------------------
|
||||
# grid = GridContainer(width_percent=100, margin=6, parent=parent_widget)
|
||||
# grid.set_cell_percentages(row_percentages=[1, 1, 1], col_percentages=[40, 60])
|
||||
# grid.set_spacing(horizontal=12, vertical=8)
|
||||
# grid.set_column_minimum_width(0, 120)
|
||||
#
|
||||
# # Добавление виджетов (row, col)
|
||||
# grid.add_widget(label1, 0, 0)
|
||||
# grid.add_widget(input1, 0, 1)
|
||||
# grid.add_widget(label2, 1, 0)
|
||||
# grid.add_widget(input2, 1, 1)
|
||||
# grid.add_widget(label3, 2, 0)
|
||||
# grid.add_widget(input3, 2, 1)
|
||||
#
|
||||
# ============================================================================
|
||||
# Пример: форма со stretch колонок (для пропорционального роста)
|
||||
# ----------------------------------------------------------------------------
|
||||
# grid = GridContainer(width_percent=100, height_percent=50)
|
||||
# grid.set_cell_percentages(row_percentages=[1, 1], col_percentages=[1, 2])
|
||||
# grid.set_spacing(horizontal=12, vertical=2)
|
||||
# grid.set_column_minimum_width(0, 140)
|
||||
#
|
||||
# # Первая колонка растёт в 1x, вторая в 2x
|
||||
# grid.add_widget(code_label, 0, 0)
|
||||
# grid.add_widget(code_input, 0, 1)
|
||||
# grid.add_widget(name_label, 1, 0)
|
||||
# grid.add_widget(name_input, 1, 1)
|
||||
#
|
||||
# ============================================================================
|
||||
# Сложная сетка с объединением ячеек (span)
|
||||
# ----------------------------------------------------------------------------
|
||||
# grid = GridContainer(width_percent=100, height_percent=100, spacing=10)
|
||||
# grid.set_cell_percentages(
|
||||
# row_percentages=[20, 30, 50],
|
||||
# col_percentages=[25, 25, 25, 25]
|
||||
# )
|
||||
#
|
||||
# # Виджет занимает 2 колонки (row_span=1, col_span=2)
|
||||
# grid.add_widget(header_widget, 0, 0, row_span=1, col_span=4)
|
||||
# grid.add_widget(sidebar_widget, 1, 0, row_span=2, col_span=1)
|
||||
# grid.add_widget(content_widget, 1, 1, row_span=2, col_span=3)
|
||||
#
|
||||
# ============================================================================
|
||||
# Важные замечания
|
||||
# ----------------------------------------------------------------------------
|
||||
# 1. set_cell_percentages ОБЯЗАТЕЛЕН перед add_widget, иначе IndexError
|
||||
# 2. row_percentages и col_percentages автоматически нормализуются до 100%
|
||||
# Например: [1, 2, 1] → [25%, 50%, 25%]
|
||||
# 3. spacing применяется ко всем ячейкам; set_spacing позволяет раздельный
|
||||
# horizontal/vertical spacing для более тонкой настройки
|
||||
# 4. Minimum width/height гарантируют, что ячейка не сожмётся ниже лимита
|
||||
# даже при малом размере родителя
|
||||
# 5. Для форм с парами label-input рекомендуется:
|
||||
# - col_percentages=[1, 2] (label уже, input шире)
|
||||
# - set_column_minimum_width(0, 120-140) для label
|
||||
# - horizontal_spacing=12, vertical_spacing=2-8
|
||||
# ============================================================================
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Сеточный контейнер с размерами в процентах от родителя. Обеспечивает
|
||||
# размещение виджетов в строках/столбцах с процентным распределением,
|
||||
# поддержкой span (объединения ячеек), выравнивания через spring-based
|
||||
# alignment и стилизации через APP_STYLES/тему.
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: Qt, QGridLayout, QLayout, QWidget, QSizePolicy (PySide6)
|
||||
# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO)
|
||||
# Внутренние: ContentHost (content_host.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс GridContainer — сеточный контейнер.
|
||||
# Методы: set_cell_percentages(), normalize_percentages(), add_widget(),
|
||||
# set_spacing(), get_layout(), set_margins(), set_alignment(),
|
||||
# set_column_minimum_width(), set_column_min_widths(),
|
||||
# set_row_stretches(), set_col_stretches(),
|
||||
# set_row_minimum_height(), set_row_min_heights(),
|
||||
# get_available_size_for_content()
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# _outer_layout : QGridLayout — внешний layout (spring-based alignment).
|
||||
# _content_host : ContentHost — промежуточный хост для внутренней сетки.
|
||||
# _grid_widget : QWidget — виджет, содержащий внутренний QGridLayout.
|
||||
# _layout : QGridLayout — внутренний layout для пользовательских ячеек.
|
||||
# _row_percentages: list[float] — нормализованные доли строк (до 100%).
|
||||
# _col_percentages: list[float] — нормализованные доли столбцов (до 100%).
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(w%, h%, parent)
|
||||
# -> _init_stylable() -> создание _outer_layout (QGridLayout)
|
||||
# -> создание ContentHost -> создание _grid_widget + _layout (QGridLayout)
|
||||
# -> _content_host.add_widget(_grid_widget)
|
||||
# -> set_cell_percentages() если row/col_percentages заданы
|
||||
# -> set_row_stretches() / set_col_stretches() если заданы
|
||||
# -> _apply_style() если style/active_style/is_active заданы
|
||||
# add_widget(widget, row, col, ...) -> проверка bounds -> _layout.addWidget()
|
||||
# set_cell_percentages(rows, cols) -> normalize -> setRowStretch/setColumnStretch
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# Мутирует stretch-значения QGridLayout при set_cell_percentages/stretches.
|
||||
# _apply_style() устанавливает stylesheet на self.
|
||||
# set_alignment() бросает NotImplementedError — запрещён.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ размещает виджеты автоматически — требует явного add_widget(row, col).
|
||||
# НЕ поддерживает alignment.
|
||||
# НЕ управляет auto_add_children (нет флага _auto_add_children).
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# set_cell_percentages: ValueError при пустых row/col_percentages.
|
||||
# add_widget: IndexError при выходе за пределы сетки.
|
||||
# set_column_minimum_width / set_row_minimum_height: ValueError при index < 0.
|
||||
# _parse_alignment_springs: удалён, alignment запрещён.
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - set_cell_percentages ОБЯЗАТЕЛЕН до add_widget, иначе IndexError.
|
||||
# - row/col_percentages нормализуются до суммы 100%.
|
||||
# - content_margins и margin — не одно и то же: content_margins приоритетнее.
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# Не менять двухуровневую компоновку (outer_layout → content_host → grid).
|
||||
# Не добавлять alignment в конструктор — запрещено правилами.
|
||||
# Примеры использования — в блоке комментариев внизу файла.
|
||||
101
Dispatch_V0.1.1/gui/containers/h_container.py
Normal file
101
Dispatch_V0.1.1/gui/containers/h_container.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/h_container.py
|
||||
"""Горизонтальный контейнер — тонкая обёртка над SContainer(orientation='h').
|
||||
|
||||
Удобство: ``HContainer(height_percent=10)`` вместо
|
||||
``SContainer(height_percent=10, orientation='h')``.
|
||||
|
||||
Ширина всегда Expanding (width_percent=None).
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from .s_container import SContainer
|
||||
|
||||
|
||||
class HContainer(SContainer):
|
||||
"""Горизонтальный контейнер с высотой в процентах от родителя."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
height_percent: int | float | None = None,
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
spacing: int = 0,
|
||||
content_width_percent: int | None = None,
|
||||
content_height_percent: int | None = None,
|
||||
content_width: int | None = None,
|
||||
content_height: int | None = None,
|
||||
content_fit: bool = True,
|
||||
content_driven: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
style: str | None = None,
|
||||
active_style: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
width_percent=None,
|
||||
height_percent=height_percent,
|
||||
margin=margin,
|
||||
spacing=spacing,
|
||||
orientation="h",
|
||||
content_width_percent=content_width_percent,
|
||||
content_height_percent=content_height_percent,
|
||||
content_width=content_width,
|
||||
content_height=content_height,
|
||||
content_fit=content_fit,
|
||||
content_driven=content_driven,
|
||||
parent=parent,
|
||||
style=style,
|
||||
active_style=active_style,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Горизонтальный контейнер — тонкая обёртка над SContainer с фиксированной
|
||||
# ориентацией "h". Удобный синтаксический сахар: HContainer(height_percent=10)
|
||||
# вместо SContainer(height_percent=10, orientation='h'). Ширина всегда
|
||||
# Expanding (width_percent=None по умолчанию).
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: QWidget (PySide6)
|
||||
# Хост/базовый класс: SContainer (s_container.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс HContainer — горизонтальный контейнер с высотой в процентах.
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# Собственных полей нет — всё наследуется от SContainer.
|
||||
# Параметр alignment в __init__ принимается, но игнорируется
|
||||
# (deprecated, для обратной совместимости).
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(width_percent=None, orientation="h", ...)
|
||||
# Все методы делегируются SContainer: add_widget(), add_stretch(),
|
||||
# set_margins(), set_spacing(), get_layout() и т.д.
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# Те же, что и у SContainer: стилизация через _apply_style(),
|
||||
# флаг _auto_add_children=True, subscribe на theme_bus.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ добавляет собственной логики — только фиксирует orientation="h"
|
||||
# и width_percent=None. Вся логика — в SContainer.
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# Делегируется SContainer.
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - width_percent всегда None → горизонтальная ось Expanding.
|
||||
# - orientation всегда "h".
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# Не добавлять сюда логику. Если нужна новая функциональность —
|
||||
# добавлять в SContainer, чтобы HContainer и VContainer наследовали
|
||||
# её автоматически.
|
||||
349
Dispatch_V0.1.1/gui/containers/percent_sized_widget.py
Normal file
349
Dispatch_V0.1.1/gui/containers/percent_sized_widget.py
Normal 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
|
||||
197
Dispatch_V0.1.1/gui/containers/s_container.py
Normal file
197
Dispatch_V0.1.1/gui/containers/s_container.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/s_container.py
|
||||
"""Универсальный контейнер с процентным масштабированием по обеим осям.
|
||||
|
||||
Orientation ("v" | "h") определяет направление стэкирования потомков.
|
||||
По умолчанию "v" — вертикальный стэк (самый частый паттерн).
|
||||
|
||||
При width_percent=None ось X — Expanding.
|
||||
При height_percent=None ось Y — Expanding.
|
||||
Таким образом:
|
||||
SContainer() → растягивается по обеим осям
|
||||
SContainer(width_percent=30) → ≡ VContainer (фикс. ширина, свободная высота)
|
||||
SContainer(height_percent=20) → ≡ HContainer (фикс. высота, свободная ширина)
|
||||
SContainer(width_percent=30, height_percent=50) → обе оси фиксированы
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QLayout, QWidget
|
||||
from .percent_sized_widget import PercentSizedWidget
|
||||
from .content_host import ContentHost
|
||||
|
||||
|
||||
class SContainer(PercentSizedWidget):
|
||||
"""Универсальный контейнер с процентным масштабированием."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width_percent: int | float | None = None,
|
||||
height_percent: int | float | None = None,
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
spacing: int = 0,
|
||||
orientation: str = "v",
|
||||
content_width_percent: int | None = None,
|
||||
content_height_percent: int | None = None,
|
||||
content_width: int | None = None,
|
||||
content_height: int | None = None,
|
||||
content_fit: bool = True,
|
||||
content_driven: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
style: str | None = None,
|
||||
active_style: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
):
|
||||
super().__init__(width_percent, height_percent, parent, content_driven=content_driven)
|
||||
self._init_stylable()
|
||||
self._auto_add_children = True
|
||||
|
||||
# Внешний layout — единственный, без промежуточного QGridLayout.
|
||||
if orientation == "h":
|
||||
self._layout: QLayout = QHBoxLayout(self)
|
||||
else:
|
||||
self._layout: QLayout = QVBoxLayout(self)
|
||||
self._layout.setSpacing(0)
|
||||
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
self._layout.setContentsMargins(*margin)
|
||||
else:
|
||||
self._layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
self._content_host = ContentHost(
|
||||
width_percent=content_width_percent,
|
||||
height_percent=content_height_percent,
|
||||
orientation=orientation,
|
||||
margin=0,
|
||||
spacing=spacing,
|
||||
parent=self,
|
||||
)
|
||||
self._content_host.set_content_fit(content_fit)
|
||||
|
||||
if content_width is not None or content_height is not None:
|
||||
w = content_width if content_width is not None else self._content_host.sizeHint().width()
|
||||
h = content_height if content_height is not None else self._content_host.sizeHint().height()
|
||||
self._content_host.set_fixed_size(w, h)
|
||||
|
||||
self._layout.addWidget(self._content_host)
|
||||
|
||||
if style is not None or active_style is not None or is_active is not None:
|
||||
self._apply_style(style_key=style, active_key=active_style, is_active=is_active)
|
||||
|
||||
# ── публичный API ──
|
||||
|
||||
def add_widget(self, widget: QWidget, alignment=None) -> None:
|
||||
"""Добавляет виджет в layout контейнера."""
|
||||
self._content_host.add_widget(widget)
|
||||
|
||||
def insert_widget(self, index: int, widget: QWidget) -> None:
|
||||
"""Вставляет виджет в layout контента по индексу."""
|
||||
self._content_host.insert_widget(index, widget)
|
||||
|
||||
def remove_widget(self, widget: QWidget) -> None:
|
||||
"""Удаляет виджет из layout контента."""
|
||||
self._content_host.remove_widget(widget)
|
||||
|
||||
def add_widget_with_stretch(self, widget: QWidget, stretch: int, alignment=None) -> None:
|
||||
self._content_host.add_widget_with_stretch(widget, stretch)
|
||||
|
||||
def add_stretch(self, stretch: int = 1) -> None:
|
||||
self._content_host.add_stretch(stretch)
|
||||
|
||||
def invalidate_layout(self) -> None:
|
||||
self._content_host.get_layout().invalidate()
|
||||
|
||||
def get_layout(self) -> QLayout:
|
||||
return self._layout
|
||||
|
||||
def set_margins(self, margin: int | tuple[int, int, int, int]) -> None:
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
self._layout.setContentsMargins(*margin)
|
||||
else:
|
||||
self._layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def set_spacing(self, spacing: int) -> None:
|
||||
self._content_host.get_layout().setSpacing(spacing)
|
||||
|
||||
def set_alignment(self, alignment: str) -> None:
|
||||
raise NotImplementedError("Qt alignment for containers is disabled; use content springs.")
|
||||
|
||||
def set_widget_alignment(self, widget: QWidget, alignment: str) -> None:
|
||||
raise NotImplementedError("Qt alignment for containers is disabled; use content springs.")
|
||||
|
||||
def get_available_size_for_content(self) -> tuple[int, int]:
|
||||
"""Полезная внутренняя область (без учёта margin)."""
|
||||
margins = self._layout.contentsMargins()
|
||||
w = self.width() - margins.left() - margins.right()
|
||||
h = self.height() - margins.top() - margins.bottom()
|
||||
return max(0, w), max(0, h)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Универсальный контейнер с процентным масштабированием по обеим осям.
|
||||
# Ориентация ("v"|"h") определяет направление стэкирования потомков.
|
||||
# Является базовым классом для VContainer и HContainer.
|
||||
# Эквивалентности: SContainer(w%=30) ≡ VContainer(w%=30),
|
||||
# SContainer(h%=20) ≡ HContainer(h%=20).
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: QVBoxLayout, QHBoxLayout, QLayout, QWidget (PySide6)
|
||||
# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO)
|
||||
# Внутренние: ContentHost (content_host.py), StylableMixin (stylable_mixin.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс SContainer — универсальный контейнер.
|
||||
# Методы: add_widget(), add_widget_with_stretch(), add_stretch(),
|
||||
# invalidate_layout(), get_layout(), set_margins(),
|
||||
# set_spacing(), set_alignment(), set_widget_alignment(),
|
||||
# get_available_size_for_content()
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# _layout : QVBoxLayout|QHBoxLayout — внешний layout.
|
||||
# _content_host : ContentHost — промежуточный хост для потомков.
|
||||
# _auto_add_children : bool = True — дочерние виджеты авто-добавляются.
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(w%, h%, parent)
|
||||
# -> _init_stylable() -> создание QVBoxLayout/QHBoxLayout по orientation
|
||||
# -> setSpacing(0) на _layout -> setContentsMargins(margin)
|
||||
# -> создание ContentHost(orientation, spacing)
|
||||
# -> _content_host.set_content_fit(content_fit)
|
||||
# -> set_fixed_size(content_width, content_height) если заданы
|
||||
# -> _layout.addWidget(_content_host)
|
||||
# -> _apply_style() если style задан
|
||||
# add_widget(w) -> _content_host.add_widget(w)
|
||||
# set_spacing(s) -> _content_host.get_layout().setSpacing(s)
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# _auto_add_children = True — дочерние PercentSizedWidget авто-добавляются.
|
||||
# set_alignment() и set_widget_alignment() бросают NotImplementedError.
|
||||
# _apply_style() устанавливает stylesheet.
|
||||
# Подписка на theme_bus через StylableMixin.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ поддерживает spring-based alignment (в отличие от GridContainer).
|
||||
# НЕ содержит сетку — только линейный layout.
|
||||
# НЕ управляет scroll — это ScrollContainer.
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# set_alignment() → NotImplementedError.
|
||||
# set_widget_alignment() → NotImplementedError.
|
||||
# Для обоих: «Qt alignment for containers is disabled; use content springs.»
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - orientation ∈ {"v", "h"}, иначе умолчание — вертикальный.
|
||||
# - При w%=None → ось X = Expanding; при h%=None → ось Y = Expanding.
|
||||
# - spacing внешнего _layout всегда 0 (spacing применяется к ContentHost).
|
||||
# - alignment параметр в __init__ — deprecated, игнорируется.
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# Новая логика должна быть совместима с VContainer/HContainer —
|
||||
# они наследуют SContainer. Не вводить логику, специфичную только
|
||||
# для одной ориентации. Spacing: внешний layout = 0, внутренний
|
||||
# (ContentHost) = spacing параметр.
|
||||
191
Dispatch_V0.1.1/gui/containers/scroll_container.py
Normal file
191
Dispatch_V0.1.1/gui/containers/scroll_container.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/scroll_container.py
|
||||
"""Прокручиваемый контейнер с процентным sizing и контейнерным API проекта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QFrame, QLayout, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from .content_host import ContentHost
|
||||
from .percent_sized_widget import PercentSizedWidget
|
||||
|
||||
|
||||
class ScrollContainer(PercentSizedWidget):
|
||||
"""Контейнер-обёртка над QScrollArea с поддержкой percent sizing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width_percent: int | float | None = None,
|
||||
height_percent: int | float | None = None,
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
content_margins: int | tuple[int, int, int, int] = 0,
|
||||
spacing: int = 0,
|
||||
orientation: str = "v",
|
||||
widget_resizable: bool = True,
|
||||
vertical_scroll_bar_policy: str | Qt.ScrollBarPolicy = "as_needed",
|
||||
horizontal_scroll_bar_policy: str | Qt.ScrollBarPolicy = "always_off",
|
||||
content_fit: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
style: str | None = None,
|
||||
active_style: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
):
|
||||
super().__init__(width_percent, height_percent, parent)
|
||||
self._init_stylable()
|
||||
self._auto_add_children = True
|
||||
|
||||
self._scroll_area = QScrollArea(self)
|
||||
self._scroll_area.setWidgetResizable(bool(widget_resizable))
|
||||
self._scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
self._content_host = ContentHost(
|
||||
orientation=orientation,
|
||||
margin=content_margins,
|
||||
spacing=spacing,
|
||||
parent=self,
|
||||
)
|
||||
self._content_host.set_content_fit(bool(content_fit))
|
||||
self._content_host.get_layout().setSizeConstraint(QLayout.SizeConstraint.SetMinAndMaxSize)
|
||||
self._scroll_area.setWidget(self._content_host)
|
||||
|
||||
self._layout: QVBoxLayout = QVBoxLayout(self)
|
||||
self._layout.setSpacing(0)
|
||||
self.set_margins(margin)
|
||||
self._layout.addWidget(self._scroll_area)
|
||||
|
||||
self.set_vertical_scroll_bar_policy(vertical_scroll_bar_policy)
|
||||
self.set_horizontal_scroll_bar_policy(horizontal_scroll_bar_policy)
|
||||
|
||||
if style is not None or active_style is not None or is_active is not None:
|
||||
self._apply_style(style_key=style, active_key=active_style, is_active=is_active)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_scroll_policy(policy: str | Qt.ScrollBarPolicy) -> Qt.ScrollBarPolicy:
|
||||
if isinstance(policy, Qt.ScrollBarPolicy):
|
||||
return policy
|
||||
token = str(policy or "").strip().lower()
|
||||
mapping = {
|
||||
"as_needed": Qt.ScrollBarPolicy.ScrollBarAsNeeded,
|
||||
"always_off": Qt.ScrollBarPolicy.ScrollBarAlwaysOff,
|
||||
"always_on": Qt.ScrollBarPolicy.ScrollBarAlwaysOn,
|
||||
}
|
||||
if token not in mapping:
|
||||
raise ValueError(
|
||||
"Unknown scroll policy. Allowed: 'as_needed', 'always_off', 'always_on'."
|
||||
)
|
||||
return mapping[token]
|
||||
|
||||
def add_widget(self, widget: QWidget, alignment=None) -> None:
|
||||
self._content_host.add_widget(widget)
|
||||
|
||||
def add_widget_with_stretch(self, widget: QWidget, stretch: int, alignment=None) -> None:
|
||||
self._content_host.add_widget_with_stretch(widget, stretch)
|
||||
|
||||
def add_stretch(self, stretch: int = 1) -> None:
|
||||
self._content_host.add_stretch(stretch)
|
||||
|
||||
def invalidate_layout(self) -> None:
|
||||
self._content_host.get_layout().invalidate()
|
||||
|
||||
def get_layout(self) -> QLayout:
|
||||
return self._layout
|
||||
|
||||
def set_margins(self, margin: int | tuple[int, int, int, int]) -> None:
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
self._layout.setContentsMargins(*margin)
|
||||
else:
|
||||
self._layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def set_content_margins(self, margin: int | tuple[int, int, int, int]) -> None:
|
||||
layout = self._content_host.get_layout()
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
layout.setContentsMargins(*margin)
|
||||
else:
|
||||
layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def set_spacing(self, spacing: int) -> None:
|
||||
self._content_host.get_layout().setSpacing(spacing)
|
||||
|
||||
def set_widget_resizable(self, enabled: bool) -> None:
|
||||
self._scroll_area.setWidgetResizable(bool(enabled))
|
||||
|
||||
def set_vertical_scroll_bar_policy(self, policy: str | Qt.ScrollBarPolicy) -> None:
|
||||
self._scroll_area.setVerticalScrollBarPolicy(self._normalize_scroll_policy(policy))
|
||||
|
||||
def set_horizontal_scroll_bar_policy(self, policy: str | Qt.ScrollBarPolicy) -> None:
|
||||
self._scroll_area.setHorizontalScrollBarPolicy(self._normalize_scroll_policy(policy))
|
||||
|
||||
@property
|
||||
def scroll_area(self) -> QScrollArea:
|
||||
return self._scroll_area
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Прокручиваемый контейнер — обёртка над QScrollArea с поддержкой
|
||||
# процентного sizing и контейнерного API проекта (add_widget, стилизация,
|
||||
# тема). Используется для длинных списков, форм и панелей, которые
|
||||
# не помещаются в видимую область.
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: Qt, QFrame, QLayout, QScrollArea, QVBoxLayout, QWidget (PySide6)
|
||||
# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO)
|
||||
# Внутренние: ContentHost (content_host.py), StylableMixin (stylable_mixin.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс ScrollContainer — прокручиваемый контейнер.
|
||||
# Методы: add_widget(), add_widget_with_stretch(), add_stretch(),
|
||||
# invalidate_layout(), get_layout(), set_margins(),
|
||||
# set_content_margins(), set_spacing(), set_widget_resizable(),
|
||||
# set_vertical_scroll_bar_policy(), set_horizontal_scroll_bar_policy()
|
||||
# Свойство: scroll_area (доступ к QScrollArea).
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# _scroll_area : QScrollArea — Qt scroll area (NoFrame).
|
||||
# _content_host : ContentHost — внутренний хост с layout для потомков.
|
||||
# _layout : QVBoxLayout — внешний layout самого контейнера.
|
||||
# _auto_add_children: bool = True — потомки авто-добавляются.
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(w%, h%, parent)
|
||||
# -> _init_stylable() -> создание QScrollArea (NoFrame)
|
||||
# -> создание ContentHost(orientation, content_margins, spacing)
|
||||
# -> _content_host.set_content_fit(content_fit)
|
||||
# -> _content_host.get_layout().setSizeConstraint(SetMinAndMaxSize)
|
||||
# -> _scroll_area.setWidget(_content_host)
|
||||
# -> создание _layout (QVBoxLayout) -> _layout.addWidget(_scroll_area)
|
||||
# -> set_vertical/horizontal_scroll_bar_policy()
|
||||
# -> _apply_style() если style задан
|
||||
# add_widget(w) -> _content_host.add_widget(w)
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# ContentHost помещается внутрь QScrollArea как scrollable widget.
|
||||
# SizeConstraint = SetMinAndMaxSize — контролирует поведение scroll.
|
||||
# _auto_add_children = True — дочерние PercentSizedWidget авто-добавляются.
|
||||
# _apply_style() устанавливает stylesheet на self.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ управляет содержимым скролла — это делает ContentHost.
|
||||
# НЕ реализует собственный scroll — делегирует QScrollArea.
|
||||
# НЕ применяет alignment/springs.
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# _normalize_scroll_policy: ValueError при невалидной строке политики.
|
||||
# Допустимые значения: "as_needed", "always_off", "always_on".
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - scroll_bar_policy ∈ {"as_needed", "always_off", "always_on"} или
|
||||
# Qt.ScrollBarPolicy enum.
|
||||
# - По умолчанию vertical = as_needed, horizontal = always_off.
|
||||
# - content_fit по умолчанию False (в отличие от других контейнеров).
|
||||
# - widget_resizable по умолчанию True.
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# При изменении scroll-политик проверять комбинацию с content_fit
|
||||
# и widget_resizable — они взаимозависимы. SizeConstraint
|
||||
# (SetMinAndMaxSize) критичен для правильного поведения scroll.
|
||||
187
Dispatch_V0.1.1/gui/containers/stack_container.py
Normal file
187
Dispatch_V0.1.1/gui/containers/stack_container.py
Normal file
@@ -0,0 +1,187 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/stack_container.py
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QStackedWidget, QGridLayout, QLayout, QWidget
|
||||
from .percent_sized_widget import PercentSizedWidget
|
||||
from .content_host import ContentHost
|
||||
|
||||
|
||||
class StackContainer(PercentSizedWidget):
|
||||
"""Контейнер-обёртка над QStackedWidget с размерами в процентах от родителя."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width_percent: int | float | None = None,
|
||||
height_percent: int | float | None = None,
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
content_margins: int | tuple[int, int, int, int] | None = None,
|
||||
spacing: int = 0,
|
||||
content_width_percent: int | None = None,
|
||||
content_height_percent: int | None = None,
|
||||
content_width: int | None = None,
|
||||
content_height: int | None = None,
|
||||
content_fit: bool = True,
|
||||
parent: QWidget | None = None,
|
||||
style: str | None = None,
|
||||
active_style: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
):
|
||||
super().__init__(width_percent, height_percent, parent)
|
||||
self._init_stylable()
|
||||
self._auto_add_children = True
|
||||
|
||||
self._stack = QStackedWidget(self)
|
||||
self._content_host = ContentHost(
|
||||
width_percent=content_width_percent,
|
||||
height_percent=content_height_percent,
|
||||
orientation="v",
|
||||
margin=0,
|
||||
spacing=spacing,
|
||||
parent=self,
|
||||
)
|
||||
self._content_host.set_content_fit(content_fit)
|
||||
self._content_host.add_widget(self._stack)
|
||||
self._outer_layout = QGridLayout(self)
|
||||
self._outer_layout.setSpacing(0)
|
||||
|
||||
applied_margins = content_margins if content_margins is not None else margin
|
||||
if isinstance(applied_margins, (list, tuple)) and len(applied_margins) == 4:
|
||||
self._outer_layout.setContentsMargins(*applied_margins)
|
||||
else:
|
||||
self._outer_layout.setContentsMargins(applied_margins, applied_margins, applied_margins, applied_margins)
|
||||
|
||||
if content_width is not None or content_height is not None:
|
||||
w = content_width if content_width is not None else self._content_host.sizeHint().width()
|
||||
h = content_height if content_height is not None else self._content_host.sizeHint().height()
|
||||
self._content_host.set_fixed_size(w, h)
|
||||
|
||||
self._outer_layout.addWidget(self._content_host, 0, 0)
|
||||
self._outer_layout.setRowStretch(0, 1)
|
||||
self._outer_layout.setColumnStretch(0, 1)
|
||||
|
||||
if spacing:
|
||||
self._outer_layout.setSpacing(spacing)
|
||||
|
||||
if style is not None or active_style is not None or is_active is not None:
|
||||
self._apply_style(style_key=style, active_key=active_style, is_active=is_active)
|
||||
|
||||
# Методы работы со страницами (интерфейс похож на QStackedWidget)
|
||||
def add_widget(self, widget: QWidget) -> int:
|
||||
index = self._stack.addWidget(widget)
|
||||
if isinstance(widget, PercentSizedWidget):
|
||||
widget.schedule_percent_update()
|
||||
return index
|
||||
|
||||
def set_current_index(self, index: int) -> None:
|
||||
self._stack.setCurrentIndex(index)
|
||||
|
||||
def set_current_widget(self, widget: QWidget) -> None:
|
||||
self._stack.setCurrentWidget(widget)
|
||||
|
||||
def current_index(self) -> int:
|
||||
return self._stack.currentIndex()
|
||||
|
||||
def current_widget(self) -> QWidget | None:
|
||||
return self._stack.currentWidget()
|
||||
|
||||
def count(self) -> int:
|
||||
return self._stack.count()
|
||||
|
||||
def widget(self, index: int) -> QWidget | None:
|
||||
return self._stack.widget(index)
|
||||
|
||||
def remove_widget(self, widget: QWidget) -> None:
|
||||
self._stack.removeWidget(widget)
|
||||
|
||||
def get_available_size_for_content(self) -> tuple[int, int]:
|
||||
"""Полезная внутренняя область (без учёта margin)."""
|
||||
margins = self._outer_layout.contentsMargins()
|
||||
w = self.width() - margins.left() - margins.right()
|
||||
h = self.height() - margins.top() - margins.bottom()
|
||||
return max(0, w), max(0, h)
|
||||
|
||||
def get_layout(self) -> QLayout:
|
||||
return self._outer_layout
|
||||
|
||||
def set_margins(self, margin: int | tuple[int, int, int, int]) -> None:
|
||||
if isinstance(margin, (list, tuple)) and len(margin) == 4:
|
||||
self._outer_layout.setContentsMargins(*margin)
|
||||
else:
|
||||
self._outer_layout.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def set_spacing(self, spacing: int) -> None:
|
||||
self._outer_layout.setSpacing(spacing)
|
||||
|
||||
def set_alignment(self, alignment: str) -> None:
|
||||
raise NotImplementedError("Qt alignment for containers is disabled; use content springs.")
|
||||
|
||||
@property
|
||||
def stacked_widget(self) -> QStackedWidget:
|
||||
return self._stack
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Контейнер-обёртка над QStackedWidget с процентным sizing.
|
||||
# Реализует переключение «страниц» (одна видна
|
||||
# в каждый момент). Используется для панели плагинов, табов и т.п.
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: Qt, QStackedWidget, QGridLayout, QLayout, QWidget (PySide6)
|
||||
# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO)
|
||||
# Внутренние: ContentHost (content_host.py), StylableMixin (stylable_mixin.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс StackContainer — стековый контейнер.
|
||||
# Методы: add_widget(), set_current_index(), set_current_widget(),
|
||||
# current_index(), current_widget(), count(), widget(),
|
||||
# remove_widget(), get_available_size_for_content(),
|
||||
# get_layout(), set_margins(), set_spacing(), set_alignment()
|
||||
# Свойство: stacked_widget (доступ к QStackedWidget).
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# _stack : QStackedWidget — Qt stacked widget.
|
||||
# _content_host : ContentHost — промежуточный хост.
|
||||
# _outer_layout : QGridLayout — внешний layout (spring-based).
|
||||
# _auto_add_children : bool = True — авто-добавление потомков.
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(w%, h%, parent)
|
||||
# -> _init_stylable() -> создание QStackedWidget
|
||||
# -> создание ContentHost -> _content_host.add_widget(_stack)
|
||||
# -> создание _outer_layout (QGridLayout)
|
||||
# -> setContentsMargins(applied_margins)
|
||||
# -> _apply_style() если style задан
|
||||
# add_widget(w) -> _stack.addWidget(w) → возвращает int index
|
||||
# set_current_index(i) -> _stack.setCurrentIndex(i)
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# QStackedWidget скрывает все страницы, кроме текущей.
|
||||
# _auto_add_children = True — дочерние виджеты авто-добавляются.
|
||||
# set_alignment() бросает NotImplementedError.
|
||||
# _apply_style() применяет stylesheet.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ управляет навигацией между страницами — только хранит и показывает.
|
||||
# НЕ создаёт кнопок/табов для переключения — это делает вызывающий код.
|
||||
# НЕ поддерживает Qt alignment (только springs).
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# set_alignment() → NotImplementedError.
|
||||
# Защита от нетипичных значений alignment — параметр удалён.
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - add_widget возвращает index добавленной страницы.
|
||||
# - content_margins приоритетнее margin.
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# Интерфейс CRUD-страниц (add/remove/set_current) повторяет
|
||||
# QStackedWidget — при обновлении Qt API проверять совместимость.
|
||||
# Не добавлять alignment в конструктор — запрещено правилами.
|
||||
101
Dispatch_V0.1.1/gui/containers/v_container.py
Normal file
101
Dispatch_V0.1.1/gui/containers/v_container.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/containers/v_container.py
|
||||
"""Вертикальный контейнер — тонкая обёртка над SContainer(orientation='v').
|
||||
|
||||
Удобство: ``VContainer(width_percent=30)`` вместо
|
||||
``SContainer(width_percent=30, orientation='v')``.
|
||||
|
||||
Высота всегда Expanding (height_percent=None).
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from .s_container import SContainer
|
||||
|
||||
|
||||
class VContainer(SContainer):
|
||||
"""Вертикальный контейнер с шириной в процентах от родителя."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width_percent: int | float | None = None,
|
||||
margin: int | tuple[int, int, int, int] = 0,
|
||||
spacing: int = 0,
|
||||
content_width_percent: int | None = None,
|
||||
content_height_percent: int | None = None,
|
||||
content_width: int | None = None,
|
||||
content_height: int | None = None,
|
||||
content_fit: bool = True,
|
||||
content_driven: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
style: str | None = None,
|
||||
active_style: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
width_percent=width_percent,
|
||||
height_percent=None,
|
||||
margin=margin,
|
||||
spacing=spacing,
|
||||
orientation="v",
|
||||
content_width_percent=content_width_percent,
|
||||
content_height_percent=content_height_percent,
|
||||
content_width=content_width,
|
||||
content_height=content_height,
|
||||
content_fit=content_fit,
|
||||
content_driven=content_driven,
|
||||
parent=parent,
|
||||
style=style,
|
||||
active_style=active_style,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Вертикальный контейнер — тонкая обёртка над SContainer с фиксированной
|
||||
# ориентацией "v". Удобный синтаксический сахар: VContainer(width_percent=30)
|
||||
# вместо SContainer(width_percent=30, orientation='v'). Высота всегда
|
||||
# Expanding (height_percent=None по умолчанию).
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: QWidget (PySide6)
|
||||
# Хост/базовый класс: SContainer (s_container.py)
|
||||
# Внешние библиотеки: PySide6
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс VContainer — вертикальный контейнер с шириной в процентах.
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# Собственных полей нет — всё наследуется от SContainer.
|
||||
# Параметр alignment в __init__ принимается, но игнорируется
|
||||
# (deprecated, для обратной совместимости).
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__(params) -> super().__init__(height_percent=None, orientation="v", ...)
|
||||
# Все методы делегируются SContainer: add_widget(), add_stretch(),
|
||||
# set_margins(), set_spacing(), get_layout() и т.д.
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# Те же, что и у SContainer: стилизация через _apply_style(),
|
||||
# флаг _auto_add_children=True, subscribe на theme_bus.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# НЕ добавляет собственной логики — только фиксирует orientation="v"
|
||||
# и height_percent=None. Вся логика — в SContainer.
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# Делегируется SContainer.
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - height_percent всегда None → вертикальная ось Expanding.
|
||||
# - orientation всегда "v".
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# Не добавлять сюда логику. Если нужна новая функциональность —
|
||||
# добавлять в SContainer, чтобы VContainer и HContainer наследовали
|
||||
# её автоматически.
|
||||
Reference in New Issue
Block a user