Add Dispatch_V0.1.1
42
Dispatch_V0.1.1/gui/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/__init__.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .styles import APP_STYLES
|
||||
from .components.button import Button
|
||||
from .components.label import Label
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Лениво отдать `MainWindow`, не создавая цикл `gui -> window -> hub -> gui`."""
|
||||
if name == "MainWindow":
|
||||
from .window import MainWindow
|
||||
|
||||
return MainWindow
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
'MainWindow',
|
||||
'APP_STYLES',
|
||||
'Button',
|
||||
'Label',
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Пакетный __init__.py для gui/. Реэкспортирует ключевые классы
|
||||
# для удобного импорта: MainWindow, APP_STYLES, Button, Label.
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Реимпорт из: styles (APP_STYLES), components.button (Button),
|
||||
# components.label (Label). MainWindow импортируется лениво через
|
||||
# __getattr__, чтобы не создавать цикл с hub во время plugin-import.
|
||||
#
|
||||
# 3) Экспорт (__all__):
|
||||
# MainWindow, Button, Label.
|
||||
# Также доступен APP_STYLES (не в __all__, но импортируется).
|
||||
BIN
Dispatch_V0.1.1/gui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/gui/__pycache__/login_dialog.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/gui/__pycache__/theme_bus.cpython-313.pyc
Normal file
73
Dispatch_V0.1.1/gui/components/__init__.py
Normal 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, ...
|
||||
BIN
Dispatch_V0.1.1/gui/components/__pycache__/label.cpython-313.pyc
Normal file
132
Dispatch_V0.1.1/gui/components/_tree_node_building.py
Normal 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, '')
|
||||
235
Dispatch_V0.1.1/gui/components/_tree_state_management.py
Normal 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
|
||||
280
Dispatch_V0.1.1/gui/components/button.py
Normal 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().
|
||||
239
Dispatch_V0.1.1/gui/components/color_palette.py
Normal 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.
|
||||
153
Dispatch_V0.1.1/gui/components/color_swatch.py
Normal 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.
|
||||
281
Dispatch_V0.1.1/gui/components/combo_box.py
Normal 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().
|
||||
248
Dispatch_V0.1.1/gui/components/coordinate_input.py
Normal 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 + суффиксы.
|
||||
102
Dispatch_V0.1.1/gui/components/dialog.py
Normal 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)
|
||||
279
Dispatch_V0.1.1/gui/components/double_spin_box.py
Normal 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.
|
||||
240
Dispatch_V0.1.1/gui/components/group_box.py
Normal 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.
|
||||
BIN
Dispatch_V0.1.1/gui/components/icons/accept_wolume_black.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/accept_wolume_white.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/camera_fixation_black.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/camera_fixation_white.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_mesh_black.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_mesh_white.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_volume_black.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_volume_white.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_zone_black.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_zone_white.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_mesh_black.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_mesh_white.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_zone_black.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_zone_white.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/edit_black.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/edit_white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/logo_usms.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/measure_black.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/measure_white.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/remember_point_black.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/remember_point_white.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/select_zone_black.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/select_zone_white.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/set_step_black.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/set_step_white.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/start_point_black.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/start_point_white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/use_current_mesh_black.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/use_current_mesh_white.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
96
Dispatch_V0.1.1/gui/components/kanban_board.py
Normal 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"]
|
||||
99
Dispatch_V0.1.1/gui/components/kanban_card.py
Normal 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)
|
||||
131
Dispatch_V0.1.1/gui/components/kanban_column.py
Normal 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)
|
||||
265
Dispatch_V0.1.1/gui/components/label.py
Normal 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 —
|
||||
# при добавлении новых параметров обновлять извлечение.
|
||||
BIN
Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_Black.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_White.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 81 KiB |
BIN
Dispatch_V0.1.1/gui/components/logo/welcome_page_dark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
Dispatch_V0.1.1/gui/components/logo/welcome_page_light.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
32
Dispatch_V0.1.1/gui/components/model_view/__init__.py
Normal 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",
|
||||
]
|
||||