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,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.

View 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)

View 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}'

View 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, а не сюда.

View 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 в конструктор — запрещено правилами.
# Примеры использования — в блоке комментариев внизу файла.

View 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 наследовали
# её автоматически.

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

View 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 параметр.

View 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.

View 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 в конструктор — запрещено правилами.

View 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 наследовали
# её автоматически.