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,73 @@
# -*- coding: utf-8 -*-
# gui/components/__init__.py
"""Компоненты пользовательского интерфейса."""
from .button import Button
from .dialog import Dialog
from .label import Label
from .text_input import TextInput
from .coordinate_input import CoordinateInput
from .combo_box import ComboBox
from .double_spin_box import DoubleSpinBox
from .radio_button import RadioButton
from .radio_group import RadioGroup
from .toggle_button import ToggleButton
from .tab_button import TabButton
from .tab_widget import TabWidget
from .topology_tree_widget import TopologyTreeWidget
from .model_view_widget import ModelViewWidget
from .part_visualizer import PartVisualizer
from .photo_view_widget import PhotoViewWidget
from .springs import VSpring, HSpring
from .group_box import GroupBox
from .color_swatch import ColorSwatch
from .color_palette import ColorPalette
from .kanban_board import KanbanBoard, KanbanColumn, KanbanCard
__all__ = [
'Button',
'Dialog',
'Label',
'TextInput',
'CoordinateInput',
'ComboBox',
'DoubleSpinBox',
'RadioButton',
'RadioGroup',
'ToggleButton',
'TabButton',
'TabWidget',
'TopologyTreeWidget',
'ModelViewWidget',
'PartVisualizer',
'PhotoViewWidget',
'VSpring',
'HSpring',
'GroupBox',
'ColorSwatch',
'ColorPalette',
'KanbanBoard',
'KanbanColumn',
'KanbanCard',
]
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Пакетный __init__: реэкспорт всех публичных UI-компонентов проекта
# из единой точки входа gui.components.
#
# 2) Зависимости модуля:
# Импортирует все компоненты: Button, Label, TextInput, CoordinateInput,
# ComboBox, DoubleSpinBox, RadioButton, RadioGroup, ToggleButton,
# TabButton, TabWidget, TopologyTreeWidget, ModelViewWidget,
# PartVisualizer, PhotoViewWidget, VSpring, HSpring, GroupBox,
# ColorSwatch, ColorPalette.
#
# 3) Экспорт:
# __all__ — список из 20 публичных символов.
# Потребители импортируют: from gui.components import Button, Label, ...

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
# gui/components/_tree_node_building.py
"""Сервис построения узлов дерева и ленивой загрузки (композиция)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Any
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTreeWidgetItem
if TYPE_CHECKING:
from gui.components.topology_tree_widget import TopologyTreeWidget
class TreeNodeBuilder:
"""Создание элементов QTreeWidget и ленивая загрузка дочерних узлов."""
def __init__(self, host: "TopologyTreeWidget") -> None:
self._host = host
def load_root_nodes(self) -> None:
"""Загрузка корневых узлов (сайтов)."""
try:
root_nodes = self._host.data_loader('site', None)
for node_data in root_nodes:
item = self._create_tree_item(node_data)
self._host._tree.addTopLevelItem(item)
if node_data.has_children and not node_data.children_loaded:
stub = QTreeWidgetItem(["Загрузка..."])
stub.setData(0, Qt.UserRole, {"is_stub": True})
item.addChild(stub)
except Exception as e:
error_msg = f"Ошибка загрузки корневых узлов: {e}"
print(error_msg)
self._host.dataLoadError.emit(error_msg)
def _create_tree_item(self, node_data) -> QTreeWidgetItem:
"""Создание элемента дерева на основе данных узла."""
item = QTreeWidgetItem()
display_attrs = self._host.DISPLAY_ATTRIBUTES.get(node_data.node_type, [])
col_values = self._get_column_values(
node_data.display_data, display_attrs, node_data.node_type,
)
for i, value in enumerate(col_values):
if i < self._host._tree.columnCount():
item.setText(i, str(value))
item.setData(0, Qt.UserRole, {
'type': node_data.node_type,
'id': node_data.node_id,
'display_data': node_data.display_data,
'has_children': node_data.has_children,
'children_loaded': node_data.children_loaded,
'raw_data': node_data.raw_data,
})
item.setChildIndicatorPolicy(
QTreeWidgetItem.ShowIndicator if node_data.has_children
else QTreeWidgetItem.DontShowIndicator
)
return item
@staticmethod
def _get_column_values(
data: Dict[str, Any], attrs: list[str], node_type: str,
) -> list[str]:
"""Значения для колонок (только значения атрибутов, без ключей)."""
values = []
if attrs:
for attr in attrs:
if attr in data and data[attr]:
values.append(str(data[attr]))
break
else:
values.append("")
else:
values.append("")
if len(attrs) > 1:
other = [str(data[a]) for a in attrs[1:] if a in data and data[a]]
sep = " " if node_type == "shelf" else ", "
values.append(sep.join(other))
else:
values.append("")
return values
def load_children(self, parent_item: QTreeWidgetItem) -> None:
"""Ленивая загрузка дочерних элементов для узла."""
parent_data = parent_item.data(0, Qt.UserRole)
if not parent_data:
return
parent_type = parent_data['type']
parent_id = parent_data['id']
node_key = f"{parent_type}:{parent_id}"
if node_key in self._host._loading_nodes:
return
self._host._loading_nodes.add(node_key)
try:
if parent_item.childCount() == 1:
child = parent_item.child(0)
child_data = child.data(0, Qt.UserRole) if child else {}
if child_data and child_data.get('is_stub'):
parent_item.removeChild(child)
children_data = self._host.data_loader(
self._get_child_type(parent_type), parent_id,
)
for child_data in children_data:
child_item = self._create_tree_item(child_data)
parent_item.addChild(child_item)
if child_data.has_children and not child_data.children_loaded:
stub = QTreeWidgetItem(["Загрузка..."])
stub.setData(0, Qt.UserRole, {"is_stub": True})
child_item.addChild(stub)
parent_data['children_loaded'] = True
parent_item.setData(0, Qt.UserRole, parent_data)
self._host._tree.resizeColumnToContents(0)
except Exception as e:
msg = f"Ошибка загрузки дочерних элементов для {parent_type} {parent_id}: {e}"
print(msg)
self._host.dataLoadError.emit(msg)
err_item = QTreeWidgetItem(["Ошибка загрузки", str(e)[:50]])
err_item.setData(0, Qt.UserRole, {"is_error": True})
parent_item.addChild(err_item)
finally:
self._host._loading_nodes.remove(node_key)
@staticmethod
def _get_child_type(parent_type: str) -> str:
"""Тип дочерних элементов на основе типа родителя."""
hierarchy = {
'site': 'facility', 'facility': 'zone', 'zone': 'rack',
'rack': 'shelf', 'shelf': 'cell', 'cell': 'volume',
}
return hierarchy.get(parent_type, '')

View File

@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
# gui/components/_tree_state_management.py
"""Сервис управления состоянием дерева: поиск, раскрытие, перезагрузка (композиция)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTreeWidgetItem, QAbstractItemView
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.topology_tree_widget import TopologyTreeWidget
class TreeStateManager:
"""Поиск узлов, снимок/восстановление раскрытия, перезагрузка дерева."""
def __init__(self, host: "TopologyTreeWidget") -> None:
self._host = host
def refresh_node(self, node_type: str, node_id: str) -> None:
"""Обновление данных узла и его детей."""
found_item = self._find_item_by_data(node_type, node_id)
if found_item:
while found_item.childCount() > 0:
found_item.removeChild(found_item.child(0))
item_data = found_item.data(0, Qt.UserRole)
if item_data:
item_data['children_loaded'] = False
found_item.setData(0, Qt.UserRole, item_data)
if item_data.get('has_children'):
stub = QTreeWidgetItem(["Загрузка..."])
stub.setData(0, Qt.UserRole, {"is_stub": True})
found_item.addChild(stub)
self._host._tree.resizeColumnToContents(0)
def _find_item_by_data(self, node_type: str, node_id: str) -> Optional[QTreeWidgetItem]:
"""Поиск элемента дерева по типу и ID."""
def search(item: QTreeWidgetItem) -> Optional[QTreeWidgetItem]:
d = item.data(0, Qt.UserRole)
if d and d.get('type') == node_type and d.get('id') == node_id:
return item
for i in range(item.childCount()):
result = search(item.child(i))
if result:
return result
return None
for i in range(self._host._tree.topLevelItemCount()):
result = search(self._host._tree.topLevelItem(i))
if result:
return result
return None
def _find_or_load_item_by_data(
self, node_type: str, node_id: str,
) -> Optional[QTreeWidgetItem]:
"""Найти узел, лениво подгружая и раскрывая ветки."""
node_type = str(node_type or "")
node_id = str(node_id or "")
if not node_type or not node_id:
return None
found = self._find_item_by_data(node_type, node_id)
if found is not None:
return found
def search(item: QTreeWidgetItem) -> Optional[QTreeWidgetItem]:
d = item.data(0, Qt.UserRole) or {}
if d.get("is_stub") or d.get("is_error"):
return None
ct = str(d.get("type") or "")
ci = str(d.get("id") or "")
if ct == node_type and ci == node_id:
return item
if ct == node_type and ci != node_id:
return None
if bool(d.get("has_children")) and not bool(d.get("children_loaded")):
try:
self._host._node_builder.load_children(item)
except Exception as e:
log_exception(__name__, "_find_or_load.load_children", e)
for idx in range(item.childCount()):
result = search(item.child(idx))
if result is not None:
try:
item.setExpanded(True)
except Exception as e:
log_exception(__name__, "_find_or_load.setExpanded", e)
return result
return None
for idx in range(self._host._tree.topLevelItemCount()):
result = search(self._host._tree.topLevelItem(idx))
if result is not None:
return result
return None
@staticmethod
def _expand_item_parents(item: QTreeWidgetItem) -> None:
"""Раскрыть цепочку родителей до корня."""
parent = item.parent()
while parent is not None:
parent.setExpanded(True)
parent = parent.parent()
def clear_tree(self) -> None:
"""Полная очистка дерева."""
self._host._tree.clear()
self._host._loading_nodes.clear()
@staticmethod
def _item_key(item: QTreeWidgetItem) -> tuple[str, str] | None:
"""Уникальный ключ узла: (type, id)."""
data = item.data(0, Qt.UserRole) or {}
nt = str(data.get("type") or "")
ni = str(data.get("id") or "")
if not nt or not ni:
return None
if data.get("is_stub") or data.get("is_error"):
return None
return nt, ni
def _collect_expanded_paths(self) -> list[list[tuple[str, str]]]:
"""Снять снимок раскрытых узлов как пути от корня."""
paths: list[list[tuple[str, str]]] = []
def walk(item: QTreeWidgetItem, path: list[tuple[str, str]]) -> None:
key = self._item_key(item)
if key is None:
return
path = [*path, key]
if item.isExpanded():
paths.append(path)
for idx in range(item.childCount()):
walk(item.child(idx), path)
for idx in range(self._host._tree.topLevelItemCount()):
walk(self._host._tree.topLevelItem(idx), [])
return paths
def _find_child_by_key(self, parent: QTreeWidgetItem, key: tuple[str, str]) -> Optional[QTreeWidgetItem]:
for idx in range(parent.childCount()):
child = parent.child(idx)
if self._item_key(child) == key:
return child
return None
def _find_root_by_key(self, key: tuple[str, str]) -> Optional[QTreeWidgetItem]:
for idx in range(self._host._tree.topLevelItemCount()):
item = self._host._tree.topLevelItem(idx)
if self._item_key(item) == key:
return item
return None
def _expand_path(self, path: list[tuple[str, str]]) -> None:
"""Раскрыть путь root->... с подзагрузкой ленивых детей."""
if not path:
return
current = self._find_root_by_key(path[0])
if current is None:
return
current.setExpanded(True)
for key in path[1:]:
data = current.data(0, Qt.UserRole) or {}
if data.get("has_children") and not data.get("children_loaded"):
self._host._node_builder.load_children(current)
child = self._find_child_by_key(current, key)
if child is None:
return
child.setExpanded(True)
current = child
def _restore_tree_state(
self,
expanded_paths: list[list[tuple[str, str]]],
selected_key: tuple[str, str] | None,
) -> None:
"""Восстановить раскрытие и выбранный элемент после reload."""
for path in expanded_paths:
self._expand_path(path)
if selected_key:
sel = self._find_item_by_data(selected_key[0], selected_key[1])
if sel is not None:
self._host._tree.setCurrentItem(sel)
self._host._tree.scrollToItem(
sel, QAbstractItemView.ScrollHint.PositionAtCenter,
)
def reload_tree(self, preserve_state: bool = True) -> None:
"""Полная перезагрузка дерева с опциональным сохранением состояния."""
expanded_paths: list[list[tuple[str, str]]] = []
selected_key: tuple[str, str] | None = None
if preserve_state:
expanded_paths = self._collect_expanded_paths()
current = self._host._tree.currentItem()
if current is not None:
selected_key = self._item_key(current)
self.clear_tree()
self._host._node_builder.load_root_nodes()
if preserve_state:
self._restore_tree_state(expanded_paths, selected_key)
self._host._tree.resizeColumnToContents(0)
def select_node(
self,
node_type: str,
node_id: str,
*,
emit_selected: bool = False,
allow_load: bool = True,
expand_parents: bool = True,
) -> bool:
"""Программно выделить узел дерева. Возвращает True при успехе."""
rt = str(node_type or "")
ri = str(node_id or "")
if allow_load:
item = self._find_or_load_item_by_data(rt, ri)
else:
item = self._find_item_by_data(rt, ri)
if item is None:
return False
if expand_parents:
self._expand_item_parents(item)
self._host._tree.setCurrentItem(item)
self._host._tree.scrollToItem(
item, QAbstractItemView.ScrollHint.PositionAtCenter,
)
if emit_selected:
d = item.data(0, Qt.UserRole) or {}
if not d.get("is_stub") and not d.get("is_error"):
self._host.nodeSelected.emit(
str(d.get("type") or ""),
str(d.get("id") or ""),
dict(d.get("display_data") or {}),
)
return True

View File

@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
# gui/components/button.py
from PySide6.QtWidgets import QPushButton, QSizePolicy
from PySide6.QtCore import Slot, QSize
from PySide6.QtGui import QIcon
from gui.theme_bus import theme_bus
from gui.containers.s_container import SContainer # Импортируем кастомный контейнер
from gui.styles import APP_STYLES
class Button(SContainer):
"""Навигационная кнопка на основе кастомного контейнера SContainer."""
def __init__(self, text: str, index: int = 0, **kwargs):
# Извлекаем параметры для передачи в SContainer
width_percent = kwargs.get("width_percent", None)
height_percent = kwargs.get("height_percent", None)
margin = kwargs.get("margin", [0, 2, 0, 2])
style = kwargs.get("style", None)
active_style = kwargs.get("active_style", None)
is_active = kwargs.get("is_active", None)
content_fit = kwargs.get("content_fit", True)
parent = kwargs.get("parent", None)
# Вызываем конструктор SContainer с параметрами
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self.index = index
self._theme = "dark"
self._is_active = False
self._style_key_normal = None
self._style_key_active = None
# Создаем кнопку
self._button = QPushButton(text)
self._button.setProperty("widget_index", index)
# Добавляем кнопку в layout контейнера
super().add_widget(self._button)
# Настраиваем кнопку для заполнения всего доступного пространства
self._button.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding
)
# Флаг для отслеживания первого обновления
self._initial_update_done = False
if style is not None:
self._style_key_normal = style
self._style_key_active = active_style or style
if is_active is not None:
self._is_active = bool(is_active)
self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light"
self.style()
theme_bus.theme_changed.connect(self.set_theme)
# Иконка (опционально): путь к PNG + размер иконки внутри кнопки.
# Размер иконки — это свойство QPushButton (QSize), а не layout-геометрия;
# правило 6.7 (запрет fixed-size) распространяется на разметку, не на иконки.
icon_path = kwargs.get("icon_path", None)
icon_size = kwargs.get("icon_size", 16)
if icon_path:
self._button.setIcon(QIcon(str(icon_path)))
self._button.setIconSize(QSize(int(icon_size), int(icon_size)))
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
):
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key or style_key
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._button.setStyleSheet(APP_STYLES.get(key, ""))
return
if self._theme == "light":
if self._is_active and "STANDARD_BUTTON_LIGHT_THEME_ACTIVE" in APP_STYLES:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME_ACTIVE"])
else:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME"])
return
if self._is_active:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME_ACTIVE"])
else:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME"])
@Slot(str)
def set_theme(self, theme: str):
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return # игнорируем ошибочные значения
if self._theme == theme:
return
self._theme = theme
self.style()
# Делегируем clicked сигнал и другие методы внутренней кнопке
@property
def clicked(self):
return self._button.clicked
@property
def toggled(self):
return self._button.toggled
def click(self):
self._button.click()
def set_text(self, text: str):
self._button.setText(text)
def get_text(self) -> str:
return self._button.text()
def set_tooltip(self, text: str):
self._button.setToolTip(text)
def get_tooltip(self) -> str:
return self._button.toolTip()
def set_checkable(self, checkable: bool):
self._button.setCheckable(checkable)
def set_checked(self, checked: bool):
self._button.setChecked(checked)
def is_checked(self) -> bool:
return self._button.isChecked()
def set_enabled(self, enabled: bool):
self._button.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._button.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._button.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._button.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._button.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._button.setMinimumSize(width, height)
self._button.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_font(self, font):
self._button.setFont(font)
def set_property(self, name: str, value):
super().setProperty(name, value)
self._button.setProperty(name, value)
def set_size_policy(self, horizontal, vertical) -> None:
self._button.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
# Переопределяем add_widget, чтобы предотвратить добавление других виджетов
def add_widget(self, widget, alignment=None):
raise NotImplementedError("Button может содержать только одну кнопку")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Навигационная/функциональная кнопка, реализованная как запечатанный
# контейнер SContainer с одной внутренней QPushButton, поддерживающая
# централизованные стили APP_STYLES и автоматическое переключение
# тем (dark/light) через theme_bus.
#
# 2) Зависимости модуля:
# Импорты: QPushButton, QSizePolicy (PySide6.QtWidgets),
# Slot (PySide6.QtCore),
# theme_bus (gui.theme_bus),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс Button — публичный виджет-кнопка.
# Основные методы: style(), set_theme(), set_text(), get_text(),
# set_tooltip(), set_checkable(), set_checked(), is_checked(),
# set_enabled(), set_min_width/height(), set_max_width/height(),
# set_fixed_size(), set_font(), set_property(), set_size_policy(),
# click().
# Свойства: clicked, toggled (делегируют к внутренней QPushButton).
#
# 4) Состояние (поля):
# index: int — числовой индекс кнопки (для идентификации в группе)
# _theme: str — текущая тема ("dark" | "light")
# _is_active: bool — признак активного состояния
# _style_key_normal: str|None — ключ стиля нормального состояния
# _style_key_active: str|None — ключ стиля активного состояния
# _button: QPushButton — внутренний виджет кнопки
# _initial_update_done: bool — флаг первого обновления
#
# 5) Последовательность действий и вызовов:
# __init__(text, index, **kwargs)
# -> super().__init__(...) — инициализация SContainer
# -> QPushButton(text) — создание внутренней кнопки
# -> super().add_widget(_button) — добавление в layout контейнера
# -> style() — первичное применение стиля из APP_STYLES
# -> theme_bus.theme_changed.connect(set_theme)
# style(style_key?, active_key?, is_active?)
# -> выбор ключа на основе _is_active + _theme
# -> _button.setStyleSheet(APP_STYLES[key])
# set_theme(theme: str)
# -> _theme = theme -> style() — перерисовка стиля
# clicked / toggled (properties)
# -> делегируют к _button.clicked / _button.toggled
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на внутреннюю QPushButton.
# - Подключается к глобальному сигналу theme_bus.theme_changed при создании.
#
# 7) Границы ответственности:
# Модуль НЕ управляет layout хоста, НЕ хранит бизнес-логику,
# НЕ регистрирует обработчики кликов (это делает потребитель).
# add_widget() заблокирован — кнопка содержит только QPushButton.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError при попытке добавить
# дополнительный виджет. set_theme() молча игнорирует невалидные
# значения темы.
#
# 9) Инварианты и контракты:
# - Контейнер всегда содержит ровно одну QPushButton.
# - _theme ∈ {"dark", "light"}.
# - Если _style_key_normal задан, стиль определяется им; иначе —
# используется стандартная пара STANDARD_BUTTON_*_THEME(_ACTIVE).
#
# 10) Правило сопровождения:
# При добавлении новой темы — расширить ветку в style().
# Не добавлять дочерние виджеты внутрь Button.
# Новые делегирующие методы дублировать на _button и super().

View File

@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
# gui/components/color_palette.py
"""Палитра выбора цвета — контейнеризованный компонент."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QGridLayout, QPushButton, QSizePolicy, QWidget
from gui.containers.s_container import SContainer
# Стандартная палитра 32 цвета (4×8).
DEFAULT_PALETTE_COLORS: list[str] = [
"#FF6B6B", "#FF8E72", "#FFB26B", "#FFD56B", "#F0E96B", "#B8E86B", "#7EDB79", "#5CC8A8",
"#59B6E6", "#5F9DFF", "#7A84FF", "#A07CFF", "#C27BFF", "#E07ADB", "#F57FB1", "#D97A5C",
"#B35A4A", "#8E4A3D", "#6F5A4D", "#7E7E7E", "#5C5C5C", "#3F4A5A", "#2F6F8F", "#2F8F86",
"#3C8F52", "#6D8F3C", "#8F873C", "#8F6B3C", "#8F4F3C", "#7B3C8F", "#4F3C8F", "#3C5C8F",
]
class ColorPalette(SContainer):
"""Сетка цветных кнопок с единственным выделением.
Сигнал ``color_selected`` испускается при клике.
Публичный API — ``set_color_hex``, ``get_color_hex``, ``set_enabled``.
"""
color_selected = Signal(str) # #RRGGBB
def __init__(
self,
colors: list[str] | None = None,
columns: int = 8,
selected_index: int = 0,
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
parent: QWidget | None = None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
parent=parent,
)
self._colors: list[str] = list(colors or DEFAULT_PALETTE_COLORS)
self._columns = max(1, int(columns))
self._selected_index: int = max(0, min(int(selected_index), len(self._colors) - 1))
self._buttons: list[QPushButton] = []
# Внутренний виджет с QGridLayout — палитра динамическая,
# GridContainer не подходит (ячейки без авторасширения QPushButton).
self._grid_widget = QWidget()
self._grid_layout = QGridLayout(self._grid_widget)
self._grid_layout.setContentsMargins(0, 0, 0, 0)
self._grid_layout.setHorizontalSpacing(4)
self._grid_layout.setVerticalSpacing(4)
super().add_widget(self._grid_widget)
self._build_grid()
# ── Публичный API ─────────────────────────────────────────────
def get_color_hex(self) -> str:
"""Вернуть выбранный цвет ``#RRGGBB``."""
if not self._colors:
return "#7FB3D5"
idx = max(0, min(self._selected_index, len(self._colors) - 1))
return str(self._colors[idx])
def set_color_hex(self, color_hex: str) -> None:
"""Выбрать ближайший цвет палитры к ``color_hex``."""
idx = self._nearest_index(str(color_hex or ""))
if idx == self._selected_index:
return
self._selected_index = idx
self._refresh_selection()
def get_selected_index(self) -> int:
"""Индекс выбранного цвета."""
return self._selected_index
def set_selected_index(self, index: int) -> None:
"""Выбрать цвет по индексу."""
index = max(0, min(int(index), len(self._colors) - 1))
if index == self._selected_index:
return
self._selected_index = index
self._refresh_selection()
def set_enabled(self, enabled: bool) -> None:
"""Включить/выключить все кнопки палитры."""
for button in self._buttons:
button.setEnabled(bool(enabled))
super().set_enabled(enabled)
def add_widget(self, widget, alignment=None):
raise NotImplementedError("ColorPalette is a sealed component")
# ── Внутренние методы ─────────────────────────────────────────
def _build_grid(self) -> None:
self._buttons = []
for idx, color in enumerate(self._colors):
button = QPushButton("")
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
button.clicked.connect(lambda _checked=False, i=idx: self._on_clicked(i))
self._buttons.append(button)
row = idx // self._columns
col = idx % self._columns
self._grid_layout.addWidget(button, row, col)
self._apply_button_style(idx, selected=(idx == self._selected_index))
self._refresh_selection()
def _on_clicked(self, index: int) -> None:
index = max(0, min(int(index), len(self._colors) - 1))
self._selected_index = index
self._refresh_selection()
self.color_selected.emit(self.get_color_hex())
def _refresh_selection(self) -> None:
for idx, button in enumerate(self._buttons):
is_selected = idx == self._selected_index
button.blockSignals(True)
button.setChecked(is_selected)
button.blockSignals(False)
self._apply_button_style(idx, selected=is_selected)
def _apply_button_style(self, index: int, selected: bool) -> None:
if index < 0 or index >= len(self._buttons):
return
color = self._colors[index]
border_color = "#FFFFFF" if selected else "#5F5F5F"
border_width = 2 if selected else 1
self._buttons[index].setStyleSheet(
f"QPushButton {{"
f"background-color: {color};"
f"border: {border_width}px solid {border_color};"
f"border-radius: 2px;"
f"min-height: 18px;"
f"}}"
f"QPushButton:disabled {{"
f"background-color: {color};"
f"border: {border_width}px solid #3A3A3A;"
f"}}"
)
def _nearest_index(self, color_hex: str) -> int:
if not self._colors:
return 0
target = QColor(color_hex)
if not target.isValid():
return 0
best_idx = 0
best_dist: int | None = None
for idx, candidate_hex in enumerate(self._colors):
candidate = QColor(candidate_hex)
dr = int(target.red()) - int(candidate.red())
dg = int(target.green()) - int(candidate.green())
db = int(target.blue()) - int(candidate.blue())
dist = dr * dr + dg * dg + db * db
if best_dist is None or dist < best_dist:
best_dist = dist
best_idx = idx
return best_idx
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Палитра выбора цвета — сетка цветных кнопок (по умолчанию 4×8)
# с единственным выделением и сигналом color_selected, встроенная
# в контейнер SContainer для процентного позиционирования.
#
# 2) Зависимости модуля:
# Импорты: Qt, Signal (PySide6.QtCore),
# QColor (PySide6.QtGui),
# QGridLayout, QPushButton, QSizePolicy, QWidget (PySide6.QtWidgets),
# SContainer (gui.containers.s_container)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Константа DEFAULT_PALETTE_COLORS (list[str], 32 цвета).
# Класс ColorPalette — публичный компонент палитры.
# Сигнал: color_selected(str) — #RRGGBB при клике.
# Методы: get_color_hex(), set_color_hex(), get_selected_index(),
# set_selected_index(), set_enabled().
#
# 4) Состояние (поля):
# _colors: list[str] — массив HEX-цветов палитры
# _columns: int — количество столбцов сетки
# _selected_index: int — индекс текущего выбранного цвета
# _buttons: list[QPushButton]— массив кнопок-ячеек
# _grid_widget: QWidget — внутренний виджет с QGridLayout
# _grid_layout: QGridLayout — компоновка сетки
#
# 5) Последовательность действий и вызовов:
# __init__(colors?, columns, selected_index, ...)
# -> super().__init__(...)
# -> _grid_widget + QGridLayout — контейнер для кнопок
# -> super().add_widget(_grid_widget)
# -> _build_grid() — создание QPushButton для каждого цвета
# -> для каждого цвета: QPushButton -> setCheckable -> connect(_on_clicked)
# -> _apply_button_style(idx, selected)
# -> _refresh_selection()
# set_color_hex(color_hex)
# -> _nearest_index(color_hex) — поиск ближайшего по евклидову расстоянию RGB
# -> _refresh_selection() — обновление всех кнопок
# _on_clicked(index)
# -> _refresh_selection() -> color_selected.emit(get_color_hex())
#
# 6) Побочные эффекты:
# - Устанавливает inline stylesheet на каждую кнопку палитры.
# - Испускает сигнал color_selected при выборе цвета.
#
# 7) Границы ответственности:
# Модуль НЕ подключается к theme_bus (стили инлайновые).
# НЕ хранит «применённый» цвет зоны — только текущий выбор палитры.
# add_widget() заблокирован — компонент запечатан.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError. _nearest_index() возвращает 0
# при невалидном HEX. Индексы зажимаются в допустимый диапазон (clamp).
#
# 9) Инварианты и контракты:
# - 0 ≤ _selected_index < len(_colors).
# - Ровно одна кнопка в checked-состоянии (exclusive selection).
# - Количество кнопок == len(_colors); _columns ≥ 1.
#
# 10) Правило сопровождения:
# Для изменения набора цветов — менять DEFAULT_PALETTE_COLORS или
# передавать colors в конструктор. Не изменять логику _nearest_index
# без учёта perceptual color distance.

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# gui/components/color_swatch.py
"""Цветовой маркер — контейнеризованный компонент."""
from __future__ import annotations
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtCore import Qt, QRect
from PySide6.QtWidgets import QWidget, QSizePolicy
from gui.containers.s_container import SContainer
class ColorSwatch(SContainer):
"""Квадрат-маркер цвета зоны.
Контейнеризованный компонент gui-фреймворка.
Размеры задаются через ``width_percent`` / ``height_percent``,
отступы — через ``margin``.
"""
def __init__(
self,
color: str = "#9E9E9EC0",
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
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=height_percent,
margin=margin,
parent=parent,
style=style,
active_style=active_style,
is_active=is_active,
)
self._swatch = _SwatchCanvas(color, parent=self)
self._swatch.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding,
)
self.add_widget(self._swatch)
# ── Публичный API ─────────────────────────────────────────────
def set_color(self, color: str) -> None:
"""Установить цвет маркера (hex ``#RRGGBB`` или ``#RRGGBBAA``)."""
self._swatch.set_color(color)
def get_color(self) -> str:
"""Вернуть текущий цвет маркера."""
return self._swatch.get_color()
class _SwatchCanvas(QWidget):
"""Внутренний виджет, рисующий заливку + тонкую рамку."""
_BORDER_COLOR = QColor("#555555")
_BORDER_RADIUS = 3
def __init__(self, color: str, parent: QWidget | None = None):
super().__init__(parent)
self._qcolor = QColor(color)
self._hex = color
def set_color(self, color: str) -> None:
self._hex = color
self._qcolor = QColor(color)
self.update()
def get_color(self) -> str:
return self._hex
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.rect().adjusted(1, 1, -1, -1)
painter.setPen(QPen(self._BORDER_COLOR, 1))
painter.setBrush(QBrush(self._qcolor))
painter.drawRoundedRect(rect, self._BORDER_RADIUS, self._BORDER_RADIUS)
painter.end()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Цветовой маркер (квадрат) для визуальной индикации цвета зоны,
# реализованный как SContainer с внутренним виджетом _SwatchCanvas,
# который рисует заливку и рамку через QPainter.
#
# 2) Зависимости модуля:
# Импорты: QColor, QPainter, QPen, QBrush (PySide6.QtGui),
# Qt, QRect (PySide6.QtCore),
# QWidget, QSizePolicy (PySide6.QtWidgets),
# SContainer (gui.containers.s_container)
# Хост-класс / базовый класс: ColorSwatch -> SContainer;
# _SwatchCanvas -> QWidget
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс ColorSwatch — публичный компонент маркера цвета.
# Методы: set_color(str), get_color() -> str.
# Класс _SwatchCanvas — внутренний (непубличный).
#
# 4) Состояние (поля):
# ColorSwatch:
# _swatch: _SwatchCanvas — внутренний виджет рисования
# _SwatchCanvas:
# _qcolor: QColor — текущий цвет для заливки
# _hex: str — текущий HEX-код цвета
# _BORDER_COLOR: QColor — цвет рамки (константа #555555)
# _BORDER_RADIUS: int — радиус скругления (константа 3)
#
# 5) Последовательность действий и вызовов:
# __init__(color, ...)
# -> super().__init__(...) — SContainer
# -> _SwatchCanvas(color) -> setSizePolicy(Expanding)
# -> self.add_widget(_swatch)
# set_color(color)
# -> _swatch.set_color(color) -> QColor(color) -> update() [перерисовка]
# _SwatchCanvas.paintEvent(event)
# -> QPainter -> drawRoundedRect с заливкой _qcolor и рамкой _BORDER_COLOR
#
# 6) Побочные эффекты:
# - Перерисовывает виджет при изменении цвета (update()).
# - Никаких сигналов не испускает.
#
# 7) Границы ответственности:
# Модуль только отображает цвет. НЕ взаимодействует с theme_bus.
# НЕ обрабатывает клики. НЕ связан с палитрой или выбором цвета.
#
# 8) Обработка ошибок:
# Невалидный HEX приводит к QColor.isValid() == False, что Qt обрабатывает
# как прозрачный/чёрный. Явных проверок нет.
#
# 9) Инварианты и контракты:
# - _SwatchCanvas.paintEvent всегда рисует прямоугольник с рамкой.
# - set_color / get_color симметричны по HEX-строке.
#
# 10) Правило сопровождения:
# При необходимости нового формата цвета (HSL, RGB-кортеж) — расширять
# set_color() с конвертацией. Не менять paintEvent без проверки antialiasing.

View File

@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
# gui/components/combo_box.py
"""Обёртка над QComboBox с централизованными стилями."""
from PySide6.QtWidgets import QComboBox, QSizePolicy
from PySide6.QtCore import Slot
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
from gui.containers.s_container import SContainer
class ComboBox(SContainer):
"""Кастомный QComboBox с темизацией и стилями APP_STYLES."""
def __init__(
self,
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
style: str = "FORM_WIDGET",
active_style: str | None = None,
is_active: bool | None = None,
content_fit: bool = True,
parent=None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._combo = QComboBox()
self._combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._combo)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._combo.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._combo.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_items(self, items: list[str]) -> None:
"""Заменить список элементов."""
self._combo.clear()
self._combo.addItems(items)
def set_editable(self, editable: bool) -> None:
self._combo.setEditable(editable)
def set_enabled(self, enabled: bool) -> None:
"""Управление доступностью."""
self._combo.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
"""Минимальная ширина."""
self._combo.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
"""Минимальная высота."""
self._combo.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
"""Максимальная ширина."""
self._combo.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
"""Максимальная высота."""
self._combo.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
"""Фиксированный размер."""
self._combo.setMinimumSize(width, height)
self._combo.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_index(self, index: int) -> None:
"""Установить текущий индекс."""
self._combo.setCurrentIndex(index)
def set_current_text(self, text: str) -> None:
self._combo.setCurrentText(text)
def set_placeholder_text(self, text: str) -> None:
"""Установить текст-заполнитель (если поддерживается Qt)."""
line_edit = self._combo.lineEdit()
if line_edit is not None:
line_edit.setPlaceholderText(text)
if hasattr(self._combo, "setPlaceholderText"):
self._combo.setPlaceholderText(text)
def get_index(self) -> int:
"""Получить текущий индекс."""
return self._combo.currentIndex()
def get_current_text(self) -> str:
return self._combo.currentText()
def set_tooltip(self, text: str) -> None:
"""Подсказка."""
self._combo.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
"""Политика размеров."""
self._combo.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
@property
def current_index_changed(self):
return self._combo.currentIndexChanged
@property
def current_text_changed(self):
return self._combo.currentTextChanged
@property
def text_edited(self):
line_edit = self._combo.lineEdit()
if line_edit is None:
raise AttributeError("text_edited is unavailable for non-editable ComboBox")
return line_edit.textEdited
def set_item_enabled(self, index: int, enabled: bool) -> None:
"""Включить/выключить элемент списка по индексу."""
model = self._combo.model()
if model is None:
return
item = model.item(index) if hasattr(model, "item") else None
if item is not None and hasattr(item, "setEnabled"):
item.setEnabled(bool(enabled))
def show_popup(self) -> None:
self._combo.showPopup()
def add_widget(self, widget, alignment=None):
raise NotImplementedError("ComboBox can contain only one QComboBox")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Обёртка над QComboBox, встроенная в SContainer, с поддержкой
# централизованных стилей APP_STYLES и автоматическим
# переключением тем (dark/light) через theme_bus.
#
# 2) Зависимости модуля:
# Импорты: QComboBox, QSizePolicy (PySide6.QtWidgets),
# Slot (PySide6.QtCore),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus),
# SContainer (gui.containers.s_container)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс ComboBox — публичный виджет выпадающего списка.
# Методы: style(), set_theme(), set_items(), set_index(), get_index(),
# set_placeholder_text(), set_enabled(), set_item_enabled(),
# set_min/max_width/height(), set_fixed_size(), set_tooltip(),
# set_size_policy().
# Свойство: current_index_changed — сигнал currentIndexChanged.
#
# 4) Состояние (поля):
# _theme: str — текущая тема ("dark" | "light")
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля (по умолчанию "FORM_WIDGET")
# _style_key_normal: str|None — явный ключ нормального стиля
# _style_key_active: str|None — явный ключ активного стиля
# _combo: QComboBox — внутренний виджет
#
# 5) Последовательность действий и вызовов:
# __init__(style="FORM_WIDGET", ...)
# -> super().__init__(...)
# -> QComboBox() -> setSizePolicy -> super().add_widget(_combo)
# -> style() — первичное применение
# -> theme_bus.theme_changed.connect(set_theme)
# style(style_key?, active_key?, is_active?)
# -> определяет ключ через комбинацию base_key + тема + active
# -> _combo.setStyleSheet(APP_STYLES[key])
# set_items(items)
# -> _combo.clear() -> _combo.addItems(items)
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на внутренний QComboBox.
# - Подключается к theme_bus.theme_changed при создании.
#
# 7) Границы ответственности:
# Модуль НЕ хранит бизнес-данные выбранного элемента.
# НЕ валидирует содержимое списка.
# add_widget() заблокирован — компонент запечатан.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError.
# set_theme() молча игнорирует невалидные значения.
# set_item_enabled() безопасно пропускает отсутствующий model/item.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QComboBox.
# - _theme ∈ {"dark", "light"}.
# - Стиль разрешается по цепочке: явный ключ → base_key + суффикс темы.
#
# 10) Правило сопровождения:
# Новые стили — добавлять в APP_STYLES с суффиксами _DARK/_LIGHT/_DARK_ACTIVE/_LIGHT_ACTIVE.
# Делегирующие методы дублировать на _combo и super().

View File

@@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
# gui/components/coordinate_input.py
"""Виджет для ввода координат"""
from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy
from PySide6.QtCore import Qt, Slot
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
from error_logger import log_exception
class CoordinateInput(SContainer):
"""Виджет для ввода координат с валидацией"""
def __init__(
self,
min_value: float = 0.0,
max_value: float = 100000.0,
decimals: int = 6,
step: float = 0.000001,
min_width: int = 150,
alignment: Qt.Alignment = Qt.AlignCenter,
parent=None,
style: str = "COORDINATE_INPUT",
active_style: str | None = None,
is_active: bool | None = None,
):
super().__init__(
width_percent=None,
height_percent=None,
margin=0,
style=style,
active_style=active_style,
is_active=is_active,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._input = QDoubleSpinBox()
self._input.setRange(min_value, max_value)
self._input.setDecimals(decimals)
self._input.setSingleStep(step)
self._input.setMinimumWidth(min_width)
self._input.setAlignment(alignment)
self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._input)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._input.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._input.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_prefix(self, prefix: str):
"""Установка префикса"""
self._input.setPrefix(prefix)
def set_range(self, min_val, max_val):
"""Установка диапазона"""
self._input.setRange(min_val, max_val)
def set_decimals(self, decimals: int):
"""Установка количества десятичных знаков"""
self._input.setDecimals(decimals)
def set_step(self, step: float) -> None:
"""Установка шага"""
self._input.setSingleStep(step)
def set_value(self, value):
"""Безопасная установка значения"""
try:
self._input.setValue(float(value))
except (ValueError, TypeError) as _exc:
log_exception(__name__, "set_value", _exc)
def get_value(self):
return self._input.value()
@property
def valueChanged(self):
"""Предоставить сигнал valueChanged из внутреннего QDoubleSpinBox."""
return self._input.valueChanged
def set_enabled(self, enabled: bool) -> None:
self._input.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._input.setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._input.setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._input.setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._input.setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._input.setMinimumSize(width, height)
self._input.setMaximumSize(width, height)
def set_tooltip(self, text: str) -> None:
self._input.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
self._input.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
def add_widget(self, widget, alignment=None):
raise NotImplementedError("CoordinateInput can contain only one QDoubleSpinBox")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Виджет для ввода координат (широта, долгота и т.д.) на базе
# QDoubleSpinBox внутри SContainer, с поддержкой валидации
# диапазона, настраиваемой точностью и стилями APP_STYLES.
#
# 2) Зависимости модуля:
# Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets),
# Qt, Slot (PySide6.QtCore),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс CoordinateInput — публичный виджет ввода координат.
# Методы: style(), set_theme(), set_prefix(), set_range(),
# set_decimals(), set_step(), set_value(), get_value(),
# set_enabled(), set_min/max_width/height(), set_fixed_size(),
# set_tooltip(), set_size_policy().
# Свойство: valueChanged — сигнал QDoubleSpinBox.valueChanged.
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT")
# _style_key_normal: str|None — явный ключ нормального стиля
# _style_key_active: str|None — явный ключ активного стиля
# _input: QDoubleSpinBox — внутренний виджет ввода
#
# 5) Последовательность действий и вызовов:
# __init__(min_value, max_value, decimals, step, min_width, alignment, ...)
# -> super().__init__(...)
# -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep, setMinimumWidth
# -> super().add_widget(_input)
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# set_value(value)
# -> try float(value) -> _input.setValue()
# -> except: pass (тихое игнорирование)
# valueChanged (property)
# -> делегирует к _input.valueChanged
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QDoubleSpinBox.
# - Подключается к theme_bus.theme_changed.
#
# 7) Границы ответственности:
# Модуль НЕ интерпретирует значения координат семантически.
# НЕ выполняет геокодирование. add_widget() заблокирован.
#
# 8) Обработка ошибок:
# set_value() глотает ValueError/TypeError при некорректном вводе.
# add_widget() бросает NotImplementedError.
# set_theme() молча игнорирует невалидные темы.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QDoubleSpinBox.
# - Значение всегда в пределах [min_value, max_value].
# - decimals определяет точность отображения.
#
# 10) Правило сопровождения:
# При добавлении суффикса/префикса — использовать set_prefix().
# Стили — через APP_STYLES с ключом COORDINATE_INPUT + суффиксы.

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# gui/components/dialog.py
"""Каноническая dialog-обёртка проекта."""
from __future__ import annotations
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QDialog, QVBoxLayout
from gui.containers import VContainer
from gui.containers._widget_style_service import WidgetStyleService
from gui.theme_bus import theme_bus
class Dialog(QDialog):
"""Базовая stylable-обёртка над QDialog с корневым VContainer."""
def __init__(
self,
title: str = "",
width: int | None = None,
height: int | None = None,
modal: bool = True,
content_margin: int | tuple[int, int, int, int] = 0,
content_spacing: int = 0,
style: str = "DIALOG",
parent=None,
):
super().__init__(parent)
# Зависимости
self._style_service = WidgetStyleService(self)
# Ссылки на ключевые виджеты
self._content_container: VContainer | None = None
# Визуальная настройка
self.setModal(bool(modal))
if title:
self.setWindowTitle(title)
if width is not None and height is not None:
self.resize(width, height)
# Сборка и подключение
self._build_root(content_margin, content_spacing)
self._connect_base_signals()
# Первичная синхронизация стиля
self.style(style)
# ── Сборка интерфейса ────────────────────────────────────────
def _build_root(
self,
content_margin: int | tuple[int, int, int, int],
content_spacing: int,
) -> None:
self._content_container = VContainer(
margin=content_margin,
spacing=content_spacing,
)
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
root_layout.addWidget(self._content_container)
# ── Подключение сигналов ─────────────────────────────────────
def _connect_base_signals(self) -> None:
theme_bus.theme_changed.connect(self.set_theme)
# ── Публичный API ────────────────────────────────────────────
@property
def content_container(self) -> VContainer:
return self._content_container
def add_widget(self, widget) -> None:
self._content_container.add_widget(widget)
def add_stretch(self, stretch: int = 1) -> None:
self._content_container.add_stretch(stretch)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
self._style_service.apply(
style_key=style_key,
active_key=active_key,
is_active=is_active,
)
# ── Обработчики событий ──────────────────────────────────────
@Slot(str)
def set_theme(self, theme: str) -> None:
self._style_service.handle_theme_changed(theme)

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
# gui/components/double_spin_box.py
"""Обёртка над QDoubleSpinBox с поддержкой централизованных APP_STYLES."""
from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy
from PySide6.QtCore import Qt, Slot, Signal
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
from error_logger import log_exception
class DoubleSpinBox(SContainer):
"""Кастомный QDoubleSpinBox с переключением стилей по теме."""
stepped = Signal()
class _StepAwareSpinBox(QDoubleSpinBox):
def __init__(self, on_step, parent=None):
super().__init__(parent)
self._on_step = on_step
def stepBy(self, steps: int) -> None: # noqa: N802 (Qt API)
super().stepBy(steps)
if steps and callable(self._on_step):
self._on_step()
def __init__(
self,
min_value: float = 0.0,
max_value: float = 100000.0,
decimals: int = 0,
step: float = 1.0,
suffix: str = " мм",
keyboard_tracking: bool = True,
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
style: str = "COORDINATE_INPUT",
active_style: str | None = None,
is_active: bool | None = None,
parent=None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._input = self._StepAwareSpinBox(self._emit_stepped)
self._input.setRange(min_value, max_value)
self._input.setDecimals(decimals)
self._input.setSingleStep(step)
self._input.setSuffix(suffix)
self._input.setAlignment(Qt.AlignCenter)
self._input.setKeyboardTracking(keyboard_tracking)
self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._input)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Применить стиль по базовым/активным ключам с учётом текущей темы."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._input.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._input.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_value(self, value: float) -> None:
self._input.setValue(value)
def get_value(self) -> float:
return self._input.value()
def get_min_value(self) -> float:
return float(self._input.minimum())
def get_max_value(self) -> float:
return float(self._input.maximum())
@property
def valueChanged(self):
return self._input.valueChanged
@property
def editing_finished(self):
return self._input.editingFinished
@property
def return_pressed(self):
line_edit = self._input.lineEdit()
if line_edit is None:
return self._input.editingFinished
return line_edit.returnPressed
def set_enabled(self, enabled: bool) -> None:
self._input.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._input.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._input.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._input.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._input.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._input.setMinimumSize(width, height)
self._input.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_tooltip(self, text: str) -> None:
self._input.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
self._input.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
def set_range(self, min_value: float, max_value: float) -> None:
self._input.setRange(min_value, max_value)
def set_step(self, step: float) -> None:
self._input.setSingleStep(step)
def commit_pending_input(self) -> None:
"""Принудительно применить текст из редактора spinbox к текущему value."""
try:
self._input.interpretText()
except Exception as e:
log_exception(__name__, "commit_pending_input", e)
def _emit_stepped(self) -> None:
self.stepped.emit()
def add_widget(self, widget, alignment=None):
raise NotImplementedError("DoubleSpinBox can contain only one QDoubleSpinBox")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Обёртка над QDoubleSpinBox в SContainer для ввода числовых значений
# (размеры в мм и т.п.) с суффиксом, шагом, поддержкой APP_STYLES и
# автоматической сменой темы.
#
# 2) Зависимости модуля:
# Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets),
# Qt, Slot (PySide6.QtCore),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс DoubleSpinBox — публичный виджет числового ввода.
# Методы: style(), set_theme(), set_value(), get_value(),
# get_min_value(), get_max_value(), set_range(), set_step(),
# set_enabled(), set_min/max_width/height(), set_fixed_size(),
# set_tooltip(), set_size_policy().
# Свойства: valueChanged, editing_finished.
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT")
# _style_key_normal: str|None — явный нормальный стиль
# _style_key_active: str|None — явный активный стиль
# _input: QDoubleSpinBox — внутренний виджет
#
# 5) Последовательность действий и вызовов:
# __init__(min_value, max_value, decimals, step, suffix, keyboard_tracking, ...)
# -> super().__init__(...)
# -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep,
# setSuffix, setAlignment(Center), setKeyboardTracking
# -> super().add_widget(_input)
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# style(style_key?, active_key?, is_active?)
# -> разрешение ключа: явный → base_key + _DARK/_LIGHT + _ACTIVE
# -> _input.setStyleSheet(APP_STYLES[key])
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QDoubleSpinBox.
# - Подключается к theme_bus.theme_changed.
#
# 7) Границы ответственности:
# Модуль НЕ валидирует единицы измерения. НЕ конвертирует значения.
# add_widget() заблокирован.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError. set_theme() игнорирует
# невалидные значения.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QDoubleSpinBox.
# - Значение в пределах [min_value, max_value].
# - keyboard_tracking определяет, испускается ли valueChanged при каждом
# нажатии клавиши или только по завершении ввода.
#
# 10) Правило сопровождения:
# Отличие от CoordinateInput: суффикс, keyboard_tracking,
# decimals=0 по умолчанию. Не дублировать этот класс для целочисленного ввода —
# достаточно decimals=0.

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# gui/components/group_box.py
"""Обёртка над QGroupBox с контейнером SContainer."""
from __future__ import annotations
from PySide6.QtCore import Slot, Qt
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QWidget, QSizePolicy
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
class GroupBox(SContainer):
"""QGroupBox с централизованным стилем и внутренним layout по умолчанию."""
_ALIGN_MAP = {
"top": Qt.AlignmentFlag.AlignTop,
"bottom": Qt.AlignmentFlag.AlignBottom,
"left": Qt.AlignmentFlag.AlignLeft,
"right": Qt.AlignmentFlag.AlignRight,
"hcenter": Qt.AlignmentFlag.AlignHCenter,
"vcenter": Qt.AlignmentFlag.AlignVCenter,
"center": Qt.AlignmentFlag.AlignCenter,
}
def __init__(
self,
title: str = "",
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
content_margins: int | tuple[int, int, int, int] = 10,
spacing: int = 8,
alignment=None,
style: str = "GROUP_BOX",
active_style: str | None = None,
is_active: bool | None = None,
content_fit: bool = True,
parent=None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._group_box = QGroupBox(title)
self._group_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
self._group_layout = QVBoxLayout()
if isinstance(content_margins, (list, tuple)) and len(content_margins) == 4:
self._group_layout.setContentsMargins(*content_margins)
else:
self._group_layout.setContentsMargins(content_margins, content_margins, content_margins, content_margins)
self._group_layout.setSpacing(spacing)
if alignment is not None:
self._group_layout.setAlignment(self._normalize_alignment(alignment))
self._group_box.setLayout(self._group_layout)
super().add_widget(self._group_box)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._group_box.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._group_box.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_title(self, title: str) -> None:
self._group_box.setTitle(title)
def get_title(self) -> str:
return self._group_box.title()
def add_widget(self, widget: QWidget) -> None:
self._group_layout.addWidget(widget)
def add_stretch(self, stretch: int = 1) -> None:
self._group_layout.addStretch(stretch)
def set_margins(self, margin: int | tuple[int, int, int, int]) -> None:
if isinstance(margin, (list, tuple)) and len(margin) == 4:
self._group_layout.setContentsMargins(*margin)
else:
self._group_layout.setContentsMargins(margin, margin, margin, margin)
def set_spacing(self, spacing: int) -> None:
self._group_layout.setSpacing(spacing)
def _normalize_alignment(self, alignment: str | Qt.Alignment) -> Qt.Alignment:
"""Преобразует строковое значение alignment в Qt.Alignment."""
if isinstance(alignment, str):
key = alignment.strip().lower()
mapped = self._ALIGN_MAP.get(key)
if mapped is None:
raise ValueError(f"Unknown alignment '{key}'. Allowed: {list(self._ALIGN_MAP.keys())}")
return mapped
return alignment
def set_alignment(self, alignment: str | Qt.Alignment) -> None:
"""Устанавливает выравнивание layout (строка или Qt.Alignment)."""
self._group_layout.setAlignment(self._normalize_alignment(alignment))
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Обёртка над QGroupBox, встроенная в SContainer, с заголовком,
# внутренним QVBoxLayout для дочерних виджетов и поддержкой стилей
# APP_STYLES + theme_bus.
#
# 2) Зависимости модуля:
# Импорты: Slot, Qt (PySide6.QtCore),
# QGroupBox, QVBoxLayout, QWidget, QSizePolicy (PySide6.QtWidgets),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс GroupBox — публичный контейнерный виджет с заголовком.
# Методы: style(), set_theme(), set_title(), get_title(),
# add_widget(QWidget), add_stretch(), set_margins(),
# set_spacing(), set_alignment().
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("GROUP_BOX")
# _style_key_normal: str|None — явный нормальный стиль
# _style_key_active: str|None — явный активный стиль
# _group_box: QGroupBox — внутренний QGroupBox
# _group_layout: QVBoxLayout — layout внутри QGroupBox
# _ALIGN_MAP: dict — маппинг строковых выравниваний в Qt.Alignment
#
# 5) Последовательность действий и вызовов:
# __init__(title, content_margins, spacing, alignment, ...)
# -> super().__init__(...)
# -> QGroupBox(title) -> QVBoxLayout -> setContentsMargins, setSpacing
# -> setAlignment(если передано)
# -> _group_box.setLayout(_group_layout)
# -> super().add_widget(_group_box) — добавление QGroupBox в SContainer
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# add_widget(widget) — ПЕРЕОПРЕДЕЛЁН:
# -> _group_layout.addWidget(widget) (добавляет внутрь QGroupBox, не в SContainer)
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QGroupBox.
# - Подключается к theme_bus.theme_changed.
# - add_widget() изменяет _group_layout (не SContainer layout).
#
# 7) Границы ответственности:
# Модуль — визуальная группировка. НЕ реализует вложенные стили
# для дочерних элементов. НЕ ограничивает типы дочерних виджетов.
#
# 8) Обработка ошибок:
# _normalize_alignment() бросает ValueError при невалидной строке
# выравнивания.
#
# 9) Инварианты и контракты:
# - _group_box всегда имеет QVBoxLayout.
# - Допустимые строки выравнивания: top, bottom, left, right,
# hcenter, vcenter, center.
# - add_widget() GroupBox — НЕ запечатан, принимает любые QWidget.
#
# 10) Правило сопровождения:
# Если нужна горизонтальная компоновка внутри GroupBox —
# вкладывать HContainer в add_widget(), не менять _group_layout на QHBoxLayout.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# gui/components/kanban_board.py
"""Переиспользуемый компонент канбан-доски."""
from typing import Dict, List, Optional
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from gui.components.kanban_card import KanbanCard
from gui.components.kanban_column import KanbanColumn
from gui.containers.h_container import HContainer
from gui.containers.s_container import SContainer
class KanbanBoard(SContainer):
"""Канбан-доска из нескольких горизонтальных колонок."""
card_clicked = Signal(object)
def __init__(
self,
width_percent: Optional[int] = None,
height_percent: Optional[int] = None,
margin: int = 0,
spacing: int = 0,
parent: Optional[QWidget] = None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
spacing=spacing,
content_fit=True,
parent=parent,
)
self._columns: Dict[object, KanbanColumn] = {}
self._columns_order: List[object] = []
self._columns_container = HContainer(
spacing=4,
margin=[4, 4, 4, 4],
parent=self,
)
def add_column(
self,
column_id: object,
title: str,
color: str = "#DFE1E6",
width_percent: Optional[int] = None,
style: str = "KANBAN_COLUMN",
) -> KanbanColumn:
"""Добавить колонку на доску."""
column = KanbanColumn(
column_id=column_id,
title=title,
color=color,
style=style,
)
column.card_clicked.connect(self.card_clicked.emit)
self._columns[column_id] = column
self._columns_order.append(column_id)
self._columns_container.add_widget(column)
self._recompute_column_widths(width_percent)
return column
def get_column(self, column_id: object) -> Optional[KanbanColumn]:
"""Получить колонку по ID."""
return self._columns.get(column_id)
def get_columns(self) -> List[KanbanColumn]:
"""Список всех колонок в порядке добавления."""
return [self._columns[column_id] for column_id in self._columns_order]
def clear_all_cards(self) -> None:
"""Удалить все карточки из всех колонок."""
for column in self._columns.values():
column.clear_cards()
def _recompute_column_widths(self, explicit_width: Optional[int] = None) -> None:
n_columns = len(self._columns_order)
if n_columns == 0:
return
if explicit_width is not None:
for column_id in self._columns_order:
self._columns[column_id].set_percent_sizes(width_percent=explicit_width)
return
base_width = 100 // n_columns
remainder = 100 % n_columns
for index, column_id in enumerate(self._columns_order):
width = base_width + (1 if index < remainder else 0)
self._columns[column_id].set_percent_sizes(width_percent=width)
__all__ = ["KanbanBoard", "KanbanColumn", "KanbanCard"]

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# gui/components/kanban_card.py
"""Переиспользуемая карточка для канбан-доски."""
from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget, QSizePolicy
from gui.containers.h_container import HContainer
from gui.containers.s_container import SContainer
from gui.containers.v_container import VContainer
from gui.components.label import Label
class KanbanCard(SContainer):
"""Одна карточка на канбан-доске."""
card_clicked = Signal(object)
def __init__(
self,
card_id: object,
title: str = "",
subtitle: str = "",
status_color: str = "#DFE1E6",
style: str = "TASK_CARD",
parent: Optional[QWidget] = None,
):
super().__init__(
width_percent=100,
margin=[6, 4, 6, 4],
style=style,
content_fit=True,
parent=parent,
)
self._card_id = card_id
self._status_color = status_color
self._build_ui(title, subtitle, status_color)
@property
def card_id(self) -> object:
"""Идентификатор карточки."""
return self._card_id
def set_title(self, text: str) -> None:
"""Установить текст заголовка."""
self._title_label.set_text(text)
def set_subtitle(self, text: str) -> None:
"""Установить текст подзаголовка."""
self._subtitle_label.set_text(text)
def set_status_color(self, color: str) -> None:
"""Установить цвет полосы состояния."""
self._status_color = color
self._status_strip.setStyleSheet(
f"background-color: {color}; border: none; border-radius: 2px;"
)
def set_badge_text(self, text: str) -> None:
"""Установить текст бейджа состояния."""
self._badge_label.set_text(text)
self._badge_label.setVisible(bool(text))
def set_badge_style(self, style_key: str) -> None:
"""Установить стиль бейджа (ключ APP_STYLES)."""
self._badge_label.style(style_key=style_key)
def _build_ui(self, title: str, subtitle: str, status_color: str) -> None:
row = HContainer(parent=self)
self._status_strip = QWidget()
self._status_strip.setFixedWidth(4)
self._status_strip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self._status_strip.setStyleSheet(
f"background-color: {status_color}; border: none; border-radius: 2px;"
)
row.add_widget(self._status_strip)
content = VContainer(margin=[6, 4, 4, 4], spacing=2, parent=row)
self._badge_label = Label("", style="TASK_BADGE")
self._badge_label.setVisible(False)
self._badge_label.set_max_height(20)
content.add_widget(self._badge_label)
self._title_label = Label(title, style="TASK_CARD_TITLE")
content.add_widget(self._title_label)
self._subtitle_label = Label(subtitle, style="TASK_CARD_SUBTITLE")
content.add_widget(self._subtitle_label)
def mousePressEvent(self, event) -> None:
"""Обработка клика по карточке."""
if event.button() == Qt.LeftButton:
self.card_clicked.emit(self._card_id)
super().mousePressEvent(event)

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# gui/components/kanban_column.py
"""Переиспользуемая колонка для канбан-доски."""
from typing import List, Optional
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from gui.components.kanban_card import KanbanCard
from gui.components.label import Label
from gui.components.springs import VSpring
from gui.containers.h_container import HContainer
from gui.containers.s_container import SContainer
from gui.containers.scroll_container import ScrollContainer
from gui.containers.v_container import VContainer
class KanbanColumn(SContainer):
"""Вертикальная колонка канбан-доски с заголовком и карточками."""
card_clicked = Signal(object)
def __init__(
self,
column_id: object,
title: str = "",
color: str = "#DFE1E6",
style: str = "KANBAN_COLUMN",
parent: Optional[QWidget] = None,
):
super().__init__(
width_percent=20,
margin=[4, 0, 4, 0],
spacing=0,
style=style,
content_fit=True,
parent=parent,
)
self._column_id = column_id
self._color = color
self._cards: List[KanbanCard] = []
self._build_ui(title, color)
@property
def column_id(self) -> object:
"""Идентификатор колонки."""
return self._column_id
@property
def card_count(self) -> int:
"""Количество карточек в колонке."""
return len(self._cards)
def set_title(self, text: str) -> None:
"""Установить заголовок колонки."""
self._title_label.set_text(text)
def update_counter(self) -> None:
"""Обновить текст счётчика."""
self._counter_label.set_text(str(len(self._cards)))
def add_card(self, card: KanbanCard) -> None:
"""Добавить карточку в колонку перед нижней пружиной."""
card.card_clicked.connect(self.card_clicked.emit)
self._card_container.insert_widget(len(self._cards), card)
self._cards.append(card)
self.update_counter()
def remove_card(self, card_id: object) -> Optional[KanbanCard]:
"""Удалить карточку по ID. Возвращает удалённую карточку или None."""
for card in self._cards:
if card.card_id == card_id:
self._cards.remove(card)
self._detach_card(card)
self.update_counter()
return card
return None
def clear_cards(self) -> None:
"""Удалить все карточки из колонки."""
for card in list(self._cards):
self._detach_card(card)
self._cards.clear()
self.update_counter()
def get_card(self, card_id: object) -> Optional[KanbanCard]:
"""Получить карточку по ID."""
for card in self._cards:
if card.card_id == card_id:
return card
return None
def _build_ui(self, title: str, color: str) -> None:
header = HContainer(
height_percent=6,
margin=[8, 6, 8, 2],
parent=self,
)
color_strip = QWidget()
color_strip.setFixedWidth(4)
color_strip.setFixedHeight(16)
color_strip.setStyleSheet(
f"background-color: {color}; border: none; border-radius: 2px;"
)
header.add_widget(color_strip)
self._title_label = Label(title, style="KANBAN_COLUMN_HEADER")
header.add_widget(self._title_label)
header.add_stretch()
self._counter_label = Label("0", style="KANBAN_COUNTER")
self._counter_label.set_fixed_size(24, 24)
header.add_widget(self._counter_label)
self._scroll = ScrollContainer(
orientation="v",
spacing=0,
content_margins=[0, 2, 0, 2],
vertical_scroll_bar_policy="as_needed",
horizontal_scroll_bar_policy="always_off",
parent=self,
)
self._card_container = VContainer(spacing=2, parent=self._scroll)
self._card_container.add_widget(VSpring())
def _detach_card(self, card: KanbanCard) -> None:
self._card_container.remove_widget(card)
card.setParent(None)

View File

@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
# gui/components/label.py
"""Стандартный компонент метки."""
from PySide6.QtWidgets import QLabel, QSizePolicy
from PySide6.QtCore import Qt, Slot
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
class Label(SContainer):
"""Стандартная метка с выбором стиля по теме внутри SContainer."""
_ALIGN_MAP = {
"top": Qt.AlignmentFlag.AlignTop,
"bottom": Qt.AlignmentFlag.AlignBottom,
"left": Qt.AlignmentFlag.AlignLeft,
"right": Qt.AlignmentFlag.AlignRight,
"hcenter": Qt.AlignmentFlag.AlignHCenter,
"vcenter": Qt.AlignmentFlag.AlignVCenter,
"center": Qt.AlignmentFlag.AlignCenter,
}
def __init__(self, text: str = "", **kwargs):
width_percent = kwargs.get("width_percent", None)
height_percent = kwargs.get("height_percent", None)
margin = kwargs.get("margin", 0)
alignment = kwargs.get("alignment", Qt.AlignCenter)
style = kwargs.get("style", "WIDGET_LABEL")
active_style = kwargs.get("active_style", None)
is_active = kwargs.get("is_active", None)
content_fit = kwargs.get("content_fit", True)
word_wrap = kwargs.get("word_wrap", False)
parent = kwargs.get("parent", None)
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self._theme = "dark"
self._is_active: bool = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._label = QLabel(text)
self._apply_alignment(alignment)
if word_wrap:
self._label.setWordWrap(True)
self._label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
else:
self._label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._label)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light"
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._label.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._label.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_text(self, text: str) -> None:
self._label.setText(text)
def set_pixmap(self, pixmap) -> None:
self._label.setPixmap(pixmap)
def get_text(self) -> str:
return self._label.text()
def _apply_alignment(self, alignment) -> None:
"""Внутренний метод применения alignment с поддержкой строк."""
if isinstance(alignment, str):
key = alignment.strip().lower()
alignment = self._ALIGN_MAP.get(key)
if alignment is None:
raise ValueError(f"Unknown alignment '{key}'. Allowed: {list(self._ALIGN_MAP.keys())}")
self._label.setAlignment(alignment)
def set_alignment(self, alignment: str | Qt.Alignment) -> None:
"""Установить выравнивание текста (строка или Qt.Alignment)."""
self._apply_alignment(alignment)
def set_word_wrap(self, enabled: bool) -> None:
"""Включить / выключить перенос текста по словам."""
self._label.setWordWrap(enabled)
if enabled:
self._label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
def set_enabled(self, enabled: bool) -> None:
self._label.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._label.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._label.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._label.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._label.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._label.setMinimumSize(width, height)
self._label.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_tooltip(self, text: str) -> None:
self._label.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
self._label.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
def add_widget(self, widget, alignment=None):
raise NotImplementedError("Label can contain only one QLabel")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Стандартная текстовая метка в SContainer с поддержкой
# централизованных стилей APP_STYLES, темизации и строкового
# выравнивания.
#
# 2) Зависимости модуля:
# Импорты: QLabel, QSizePolicy (PySide6.QtWidgets),
# Qt, Slot (PySide6.QtCore),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс Label — публичный виджет метки.
# Методы: style(), set_theme(), set_text(), get_text(),
# set_alignment(), set_enabled(),
# set_min/max_width/height(), set_fixed_size(),
# set_tooltip(), set_size_policy().
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("WIDGET_LABEL")
# _style_key_normal: str|None — явный нормальный стиль
# _style_key_active: str|None — явный активный стиль
# _label: QLabel — внутренний виджет метки
# _ALIGN_MAP: dict — маппинг строк → Qt.Alignment
#
# 5) Последовательность действий и вызовов:
# __init__(text="", **kwargs)
# -> извлечение параметров из kwargs
# -> super().__init__(...)
# -> QLabel(text) -> _apply_alignment -> setSizePolicy
# -> super().add_widget(_label)
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# _apply_alignment(alignment)
# -> если str → маппинг через _ALIGN_MAP → _label.setAlignment()
# -> если Qt.Alignment → прямое применение
# -> ValueError при невалидной строке
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QLabel.
# - Подключается к theme_bus.theme_changed.
#
# 7) Границы ответственности:
# Модуль НЕ поддерживает rich-text/HTML самостоятельно (хотя QLabel
# может). НЕ обрабатывает клики. add_widget() заблокирован.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError.
# _apply_alignment() бросает ValueError при невалидной строке.
# set_theme() молча игнорирует невалидные значения.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QLabel.
# - _theme ∈ {"dark", "light"}.
# - Стиль: явный ключ → base_key + суффикс.
#
# 10) Правило сопровождения:
# Для новых стилей — добавлять в APP_STYLES с суффиксами
# _DARK/_LIGHT + _ACTIVE. __init__ принимает **kwargs —
# при добавлении новых параметров обновлять извлечение.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/__init__.py
"""Внутренние mixin-модули для ModelViewWidget."""
from gui.components.model_view._mv_model_loading import ModelLoadingMixin
from gui.components.model_view._mv_zones import ZoneManagementMixin
from gui.components.model_view._mv_interaction import InteractionMixin
from gui.components.model_view._mv_visual import VisualHelpersMixin
from gui.components.model_view._mv_grid_core import GridCoreMixin
from gui.components.model_view._mv_dimension_lines import DimensionLinesMixin
from gui.components.model_view._mv_racks import RackPlacementMixin
from gui.components.model_view._mv_presentation import ScenePresentationMixin
from gui.components.model_view._mv_scene_modes import SceneModesMixin
from gui.components.model_view._mv_rack_transition import RackCameraTransitionMixin
from gui.components.model_view._mv_zone_transition import ZoneCameraTransitionMixin
from gui.components.model_view._mv_racks_io import RackPlacementIOMixin
__all__ = [
"ModelLoadingMixin",
"ZoneManagementMixin",
"InteractionMixin",
"VisualHelpersMixin",
"GridCoreMixin",
"DimensionLinesMixin",
"RackPlacementMixin",
"ScenePresentationMixin",
"SceneModesMixin",
"RackCameraTransitionMixin",
"ZoneCameraTransitionMixin",
"RackPlacementIOMixin",
]

Some files were not shown because too many files have changed in this diff Show More