1484 lines
67 KiB
Python
1484 lines
67 KiB
Python
from __future__ import annotations
|
||
from copy import deepcopy
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import QSignalBlocker, QSize, Qt
|
||
from PySide6.QtGui import QAction, QColor, QKeySequence
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QAbstractScrollArea,
|
||
QApplication,
|
||
QFileDialog,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QStackedWidget,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QToolButton,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
try:
|
||
from .canvas import LedCanvas
|
||
from .icons import lucide_icon
|
||
from .models import LedBlock, LedLine, MappingDocument
|
||
from .panel_mapping_generator import generate_panel_mapping_from_document
|
||
from .unfolding import MAX_LINES
|
||
except ImportError:
|
||
from canvas import LedCanvas
|
||
from icons import lucide_icon
|
||
from models import LedBlock, LedLine, MappingDocument
|
||
from panel_mapping_generator import generate_panel_mapping_from_document
|
||
from unfolding import MAX_LINES
|
||
|
||
|
||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||
SORT_ROLE = Qt.UserRole + 1
|
||
SIDEBAR_DEFAULT_WIDTH = 260
|
||
SIDEBAR_MIN_WIDTH = 180
|
||
SIDEBAR_COLLAPSED_WIDTH = 34
|
||
MAX_WIDGET_WIDTH = 16777215
|
||
|
||
|
||
class SortableTableItem(QTableWidgetItem):
|
||
def __lt__(self, other: QTableWidgetItem) -> bool:
|
||
left = self.data(SORT_ROLE)
|
||
right = other.data(SORT_ROLE)
|
||
if left is not None and right is not None:
|
||
return left < right
|
||
return super().__lt__(other)
|
||
|
||
|
||
class SidebarTab(QWidget):
|
||
def __init__(self, expand_handler, parent: QWidget | None = None) -> None: # noqa: ANN001
|
||
super().__init__(parent)
|
||
self._expand_handler = expand_handler
|
||
self.setCursor(Qt.PointingHandCursor)
|
||
self.setToolTip("Показать боковую панель")
|
||
|
||
def mousePressEvent(self, event) -> None: # noqa: ANN001
|
||
if event.button() == Qt.LeftButton:
|
||
self._expand_handler()
|
||
event.accept()
|
||
return
|
||
super().mousePressEvent(event)
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.setWindowTitle("LED Mapper Editor")
|
||
self.resize(1280, 820)
|
||
|
||
self.document = MappingDocument()
|
||
self.active_line_id: int | None = None
|
||
self._updating_table = False
|
||
self._dirty = False
|
||
self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
|
||
self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
|
||
self.actions: dict[str, QAction] = {}
|
||
self.side_buttons: dict[str, QPushButton] = {}
|
||
self.block_append_buttons: dict[tuple[int, int], QPushButton] = {}
|
||
self.brush_target: tuple[int, int] | None = None
|
||
self._brush_stroke_started = False
|
||
self._brush_pending_point_ids: list[int] = []
|
||
self._brush_pending_point_set: set[int] = set()
|
||
self.sidebar_splitter: QSplitter | None = None
|
||
self.sidebar_stack: QStackedWidget | None = None
|
||
self.side_panel: QWidget | None = None
|
||
self.sidebar_tab: QToolButton | None = None
|
||
self.sidebar_tab_container: SidebarTab | None = None
|
||
self._sidebar_collapsed = False
|
||
self._sidebar_last_width = SIDEBAR_DEFAULT_WIDTH
|
||
|
||
self.canvas = LedCanvas()
|
||
self.canvas.selectionChanged.connect(self._on_selection_changed)
|
||
self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments)
|
||
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
|
||
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
|
||
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
|
||
self.tree = QTreeWidget()
|
||
self.tree.setHeaderLabels(["Линии и блоки"])
|
||
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.tree.itemSelectionChanged.connect(self._on_tree_selection)
|
||
self.tree.customContextMenuRequested.connect(self._show_tree_menu)
|
||
|
||
self.table = QTableWidget(0, 10)
|
||
self.table.setHorizontalHeaderLabels(
|
||
["UID", "Источник", "Ряд", "Кол.", "Линия", "Блок", "L1", "L2", "L3", "L4"]
|
||
)
|
||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
|
||
self.table.horizontalHeader().setMinimumSectionSize(24)
|
||
self.table.horizontalHeader().setStretchLastSection(False)
|
||
for column, width in enumerate((54, 72, 54, 54, 58, 58, 48, 48, 48, 48)):
|
||
self.table.setColumnWidth(column, width)
|
||
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.table.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
|
||
self.table.setMinimumWidth(0)
|
||
self.table.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
|
||
self.table.setAlternatingRowColors(True)
|
||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||
self.table.setSortingEnabled(True)
|
||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.table.itemSelectionChanged.connect(self._select_rows_on_canvas)
|
||
self.table.customContextMenuRequested.connect(self._show_table_menu)
|
||
|
||
self.summary_label = QLabel("Файл: - | Точек: 0 | Линий: 0/4 | Блоков: 0 | Назначено: 0")
|
||
self.active_label = QLabel("Активная линия: -")
|
||
self.selection_label = QLabel("Выбрано: 0 LED")
|
||
self.status = QLabel("Откройте circles.json или ordered_circles.json")
|
||
for label in (self.summary_label, self.active_label, self.selection_label, self.status):
|
||
label.setMinimumWidth(0)
|
||
label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||
label.setWordWrap(False)
|
||
self.tree.setMinimumWidth(0)
|
||
self.tree.setTextElideMode(Qt.ElideRight)
|
||
self.tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.tree.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
|
||
self.tree.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
|
||
self._build_actions()
|
||
self._build_layout()
|
||
application = QApplication.instance()
|
||
if application is not None:
|
||
application.focusChanged.connect(lambda _old, _new: self._refresh_ui_state())
|
||
self._refresh_ui_state()
|
||
|
||
def _build_actions(self) -> None:
|
||
def add_action(
|
||
key: str,
|
||
title: str,
|
||
handler,
|
||
icon_name: str | None = None,
|
||
shortcut: QKeySequence | None = None,
|
||
tooltip: str | None = None,
|
||
) -> QAction:
|
||
action = QAction(title, self)
|
||
if icon_name is not None:
|
||
action.setIcon(lucide_icon(icon_name))
|
||
if shortcut is not None:
|
||
action.setShortcut(shortcut)
|
||
action.setShortcutContext(Qt.ApplicationShortcut)
|
||
if tooltip is not None:
|
||
action.setToolTip(tooltip)
|
||
action.setStatusTip(tooltip)
|
||
action.triggered.connect(handler)
|
||
self.actions[key] = action
|
||
return action
|
||
|
||
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
|
||
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
|
||
add_action("save_as", "Сохранить как", self.save_file_as, "save-all", QKeySequence.SaveAs)
|
||
add_action(
|
||
"generate_script",
|
||
"Сгенерировать скрипт",
|
||
self.generate_script,
|
||
"file-code",
|
||
QKeySequence("Ctrl+G"),
|
||
"Сгенерировать vm_map.h и вспомогательные файлы развертки",
|
||
)
|
||
add_action("add_line", "Новая линия", self.add_line, "file-plus", QKeySequence("Ctrl+L"))
|
||
add_action("add_block", "Блок из выбора", self.add_block_from_selection, "plus", None, "Создать блок в активной линии")
|
||
add_action("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "layers", QKeySequence("Ctrl+Shift+B"), "Разбить выделение на связанные блоки")
|
||
add_action("clockwise", "По часовой", self.auto_connect_active_line, "rotate-cw", None, "Пересчитать порядок LED по часовой стрелке")
|
||
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм")
|
||
brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме")
|
||
brush_action.setCheckable(True)
|
||
add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo)
|
||
add_action("redo", "Повторить", self.redo_last_change, "redo-2", QKeySequence.Redo)
|
||
self.delete_action = add_action(
|
||
"delete",
|
||
"Удалить фиксацию",
|
||
self.delete_current_assignment,
|
||
"trash-2",
|
||
QKeySequence.Delete,
|
||
"Удалить выбранные назначения LED, блок или линию",
|
||
)
|
||
add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("Ctrl+Shift+Del"))
|
||
add_action("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc"))
|
||
add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0"))
|
||
grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм")
|
||
grid_action.setCheckable(True)
|
||
arrows_action = add_action(
|
||
"direction_arrows",
|
||
"Стрелки направления",
|
||
self.toggle_direction_arrows,
|
||
"arrow-right",
|
||
QKeySequence("Ctrl+Shift+A"),
|
||
"Показать или скрыть стрелки направления на линиях",
|
||
)
|
||
arrows_action.setCheckable(True)
|
||
arrows_action.setChecked(True)
|
||
add_action("toggle_sidebar", "Скрыть боковую панель", self.toggle_sidebar, "chevron-right", QKeySequence("F9"))
|
||
self._build_menu_bar()
|
||
self.addAction(self.delete_action)
|
||
|
||
def _build_menu_bar(self) -> None:
|
||
menu_bar = self.menuBar()
|
||
menu_bar.clear()
|
||
file_menu = menu_bar.addMenu("Файл")
|
||
file_menu.addAction(self.actions["open"])
|
||
file_menu.addAction(self.actions["save"])
|
||
file_menu.addAction(self.actions["save_as"])
|
||
file_menu.addSeparator()
|
||
file_menu.addAction(self.actions["generate_script"])
|
||
|
||
edit_menu = menu_bar.addMenu("Правка")
|
||
edit_menu.addAction(self.actions["undo"])
|
||
edit_menu.addAction(self.actions["redo"])
|
||
edit_menu.addSeparator()
|
||
edit_menu.addAction(self.actions["delete"])
|
||
edit_menu.addAction(self.actions["clear_all"])
|
||
edit_menu.addAction(self.actions["clear_selection"])
|
||
|
||
view_menu = menu_bar.addMenu("Вид")
|
||
view_menu.addAction(self.actions["fit_view"])
|
||
view_menu.addAction(self.actions["grid"])
|
||
view_menu.addAction(self.actions["direction_arrows"])
|
||
|
||
window_menu = menu_bar.addMenu("Окно")
|
||
window_menu.addAction(self.actions["toggle_sidebar"])
|
||
|
||
tools_menu = menu_bar.addMenu("Инструменты")
|
||
tools_menu.addAction(self.actions["add_line"])
|
||
tools_menu.addAction(self.actions["generate_script"])
|
||
tools_menu.addSeparator()
|
||
tools_menu.addAction(self.actions["auto_blocks"])
|
||
tools_menu.addSeparator()
|
||
tools_menu.addAction(self.actions["brush"])
|
||
|
||
def _build_layout(self) -> None:
|
||
side = QWidget()
|
||
self.side_panel = side
|
||
side.setMinimumWidth(0)
|
||
side.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
|
||
side_layout = QVBoxLayout(side)
|
||
side_layout.addWidget(self.summary_label)
|
||
side_layout.addWidget(self.active_label)
|
||
structure_label = QLabel("Структура")
|
||
structure_label.setMinimumWidth(0)
|
||
structure_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||
side_layout.addWidget(structure_label)
|
||
side_layout.addWidget(self.tree, 2)
|
||
|
||
buttons = QWidget()
|
||
button_layout = QHBoxLayout(buttons)
|
||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||
side_button_icons = {
|
||
"add_line": "file-plus",
|
||
"auto_blocks": "layers",
|
||
}
|
||
for key, title, handler, tooltip in (
|
||
("add_line", "Линия", self.add_line, f"Создать линию, максимум {MAX_LINES}"),
|
||
("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "Разбить выделение на связанные блоки"),
|
||
):
|
||
button = QPushButton(title)
|
||
button.setIcon(lucide_icon(side_button_icons[key]))
|
||
button.setMinimumWidth(0)
|
||
button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||
button.setToolTip(tooltip)
|
||
button.clicked.connect(handler)
|
||
button_layout.addWidget(button)
|
||
self.side_buttons[key] = button
|
||
side_layout.addWidget(buttons)
|
||
side_layout.addWidget(self.selection_label)
|
||
side_layout.addWidget(self.table, 3)
|
||
side_layout.addWidget(self.status)
|
||
|
||
collapsed_tab = SidebarTab(self.expand_sidebar)
|
||
self.sidebar_tab_container = collapsed_tab
|
||
collapsed_tab.setFixedWidth(SIDEBAR_COLLAPSED_WIDTH)
|
||
collapsed_layout = QVBoxLayout(collapsed_tab)
|
||
collapsed_layout.setContentsMargins(0, 0, 0, 0)
|
||
collapsed_layout.addStretch(1)
|
||
self.sidebar_tab = QToolButton()
|
||
self.sidebar_tab.setIcon(lucide_icon("chevron-right"))
|
||
self.sidebar_tab.setToolTip("Показать боковую панель")
|
||
self.sidebar_tab.setAutoRaise(True)
|
||
self.sidebar_tab.setCursor(Qt.PointingHandCursor)
|
||
self.sidebar_tab.clicked.connect(self.expand_sidebar)
|
||
collapsed_layout.addWidget(self.sidebar_tab, 0, Qt.AlignHCenter)
|
||
collapsed_layout.addStretch(1)
|
||
|
||
self.sidebar_stack = QStackedWidget()
|
||
self.sidebar_stack.setMinimumWidth(0)
|
||
self.sidebar_stack.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
|
||
self.sidebar_stack.addWidget(side)
|
||
self.sidebar_stack.addWidget(collapsed_tab)
|
||
|
||
splitter = QSplitter(Qt.Horizontal)
|
||
self.sidebar_splitter = splitter
|
||
splitter.setChildrenCollapsible(False)
|
||
splitter.addWidget(self.sidebar_stack)
|
||
splitter.addWidget(self.canvas)
|
||
splitter.setCollapsible(0, False)
|
||
splitter.setCollapsible(1, False)
|
||
splitter.setStretchFactor(0, 0)
|
||
splitter.setStretchFactor(1, 1)
|
||
splitter.splitterMoved.connect(self._on_sidebar_splitter_moved)
|
||
splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 1000])
|
||
self.setCentralWidget(splitter)
|
||
|
||
def _on_sidebar_splitter_moved(self, _pos: int, _index: int) -> None:
|
||
if self.sidebar_splitter is None:
|
||
return
|
||
side_width = self.sidebar_splitter.sizes()[0]
|
||
if self._sidebar_collapsed:
|
||
if side_width >= SIDEBAR_MIN_WIDTH:
|
||
self._sidebar_last_width = side_width
|
||
self.expand_sidebar()
|
||
elif side_width < SIDEBAR_COLLAPSED_WIDTH:
|
||
self._set_sidebar_splitter_width(SIDEBAR_COLLAPSED_WIDTH)
|
||
return
|
||
if side_width < SIDEBAR_MIN_WIDTH:
|
||
self.collapse_sidebar()
|
||
elif side_width >= SIDEBAR_MIN_WIDTH:
|
||
self._sidebar_last_width = side_width
|
||
|
||
def _set_sidebar_splitter_width(self, side_width: int) -> None:
|
||
if self.sidebar_splitter is None:
|
||
return
|
||
sizes = self.sidebar_splitter.sizes()
|
||
total_width = max(sum(sizes), side_width + 1)
|
||
blocker = QSignalBlocker(self.sidebar_splitter)
|
||
self.sidebar_splitter.setSizes([side_width, max(1, total_width - side_width)])
|
||
del blocker
|
||
|
||
def toggle_sidebar(self) -> None:
|
||
if self._sidebar_collapsed:
|
||
self.expand_sidebar()
|
||
else:
|
||
self.collapse_sidebar()
|
||
|
||
def toggle_direction_arrows(self) -> None:
|
||
action = self.actions.get("direction_arrows")
|
||
visible = True if action is None else action.isChecked()
|
||
self.canvas.set_direction_arrows_visible(visible)
|
||
|
||
def collapse_sidebar(self) -> None:
|
||
if self.sidebar_splitter is None or self.sidebar_stack is None or self._sidebar_collapsed:
|
||
return
|
||
sizes = self.sidebar_splitter.sizes()
|
||
if sizes and sizes[0] >= SIDEBAR_MIN_WIDTH:
|
||
self._sidebar_last_width = sizes[0]
|
||
total_width = max(sum(sizes), SIDEBAR_DEFAULT_WIDTH + 1)
|
||
blocker = QSignalBlocker(self.sidebar_splitter)
|
||
self.sidebar_stack.setCurrentIndex(1)
|
||
self.sidebar_stack.setMinimumWidth(SIDEBAR_COLLAPSED_WIDTH)
|
||
self.sidebar_stack.setMaximumWidth(MAX_WIDGET_WIDTH)
|
||
self.sidebar_splitter.setSizes([SIDEBAR_COLLAPSED_WIDTH, max(1, total_width - SIDEBAR_COLLAPSED_WIDTH)])
|
||
del blocker
|
||
self._sidebar_collapsed = True
|
||
action = self.actions.get("toggle_sidebar")
|
||
if action is not None:
|
||
action.setText("Показать боковую панель")
|
||
action.setToolTip("Показать боковую панель")
|
||
|
||
def expand_sidebar(self) -> None:
|
||
if self.sidebar_splitter is None or self.sidebar_stack is None:
|
||
return
|
||
sizes = self.sidebar_splitter.sizes()
|
||
total_width = max(sum(sizes), SIDEBAR_DEFAULT_WIDTH + 1)
|
||
side_width = max(SIDEBAR_MIN_WIDTH, self._sidebar_last_width)
|
||
if side_width >= total_width:
|
||
side_width = min(SIDEBAR_DEFAULT_WIDTH, total_width - 1)
|
||
blocker = QSignalBlocker(self.sidebar_splitter)
|
||
self.sidebar_stack.setMaximumWidth(MAX_WIDGET_WIDTH)
|
||
self.sidebar_stack.setMinimumWidth(0)
|
||
self.sidebar_stack.setCurrentIndex(0)
|
||
self.sidebar_splitter.setSizes([side_width, max(1, total_width - side_width)])
|
||
del blocker
|
||
self._sidebar_collapsed = False
|
||
action = self.actions.get("toggle_sidebar")
|
||
if action is not None:
|
||
action.setText("Скрыть боковую панель")
|
||
action.setToolTip("Скрыть боковую панель")
|
||
|
||
def open_file(self) -> None:
|
||
if not self._confirm_discard_changes():
|
||
return
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self,
|
||
"Открыть карту LED",
|
||
str(ROOT_DIR),
|
||
"JSON files (*.json);;All files (*.*)",
|
||
)
|
||
if not path:
|
||
return
|
||
loaded = MappingDocument()
|
||
try:
|
||
loaded.load(path)
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "Ошибка загрузки", str(exc))
|
||
return
|
||
self.document = loaded
|
||
self._dirty = False
|
||
self._undo_stack.clear()
|
||
self._redo_stack.clear()
|
||
self.active_line_id = self.document.lines[0].id if self.document.lines else None
|
||
self.canvas.set_document(self.document)
|
||
self.canvas.set_active_line(self.active_line_id)
|
||
self.refresh_tree()
|
||
self._sync_canvas_to_active_line()
|
||
summary = self._document_summary()
|
||
self.status.setText(f"Загружено {len(self.document.points)} точек: {summary}: {path}")
|
||
self._refresh_ui_state()
|
||
|
||
def save_file(self) -> None:
|
||
if self.document.path is None:
|
||
self.save_file_as()
|
||
return
|
||
self._save_to(self.document.path)
|
||
|
||
def save_file_as(self) -> None:
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Сохранить карту LED",
|
||
str((self.document.path or ROOT_DIR / "led_mapping_editor.json")),
|
||
"JSON files (*.json);;All files (*.*)",
|
||
)
|
||
if path:
|
||
self._save_to(Path(path))
|
||
|
||
def generate_script(self, output_dir: str | Path | None = None) -> None:
|
||
if isinstance(output_dir, bool):
|
||
output_dir = None
|
||
if not self.document.points:
|
||
QMessageBox.information(self, "Нет точек", "Откройте карту LED перед генерацией скрипта.")
|
||
self._refresh_ui_state()
|
||
return
|
||
assigned_count = self._script_assigned_count()
|
||
total_count = len(self.document.points)
|
||
if assigned_count == 0:
|
||
QMessageBox.information(
|
||
self,
|
||
"Нет назначенных LED",
|
||
"Ни один светодиод схемы не назначен. Назначьте LED в линии и блоки перед генерацией скрипта.",
|
||
)
|
||
self.status.setText("Генерация отменена: нет назначенных LED.")
|
||
self._refresh_ui_state()
|
||
return
|
||
if assigned_count < total_count and not self._confirm_partial_script_generation(assigned_count, total_count):
|
||
self.status.setText("Генерация отменена: пользователь продолжает редактирование.")
|
||
self._refresh_ui_state()
|
||
return
|
||
if output_dir is None:
|
||
selected_dir = QFileDialog.getExistingDirectory(
|
||
self,
|
||
"Выберите папку для генерации скрипта",
|
||
str(ROOT_DIR / "panel_mapping_build"),
|
||
)
|
||
if not selected_dir:
|
||
return
|
||
output_dir = selected_dir
|
||
try:
|
||
result = generate_panel_mapping_from_document(self.document, output_dir)
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "Ошибка генерации", str(exc))
|
||
self.status.setText(f"Ошибка генерации скрипта: {exc}")
|
||
self._refresh_ui_state()
|
||
return
|
||
|
||
generated_list = "\n".join(str(path) for path in result.generated_files.values())
|
||
QMessageBox.information(
|
||
self,
|
||
"Скрипт сгенерирован",
|
||
(
|
||
f"Точек: {result.led_count}\n"
|
||
f"Назначенных LED: {result.assigned_count}\n"
|
||
f"Свободных LED: {result.unassigned_count}\n"
|
||
f"Блоков: {result.block_count}\n"
|
||
f"Виртуальная матрица: {result.vm_rows} x {result.vm_cols}\n\n"
|
||
f"Файлы:\n{generated_list}"
|
||
),
|
||
)
|
||
self.status.setText(
|
||
f"Сгенерирован скрипт: {result.assigned_count}/{result.led_count} LED, "
|
||
f"матрица {result.vm_rows}x{result.vm_cols}: {result.output_dir}"
|
||
)
|
||
self._refresh_ui_state()
|
||
|
||
def _script_assigned_count(self) -> int:
|
||
return sum(1 for point in self.document.points if point.line_id is not None and point.line_index is not None)
|
||
|
||
def _confirm_partial_script_generation(self, assigned_count: int, total_count: int) -> bool:
|
||
message = QMessageBox(self)
|
||
message.setIcon(QMessageBox.Icon.Warning)
|
||
message.setWindowTitle("Назначены не все LED")
|
||
message.setText(
|
||
f"Назначено {assigned_count} из {total_count} светодиодов.\n\n"
|
||
"Сгенерировать скрипт сейчас? Неназначенные LED не попадут в рабочий маппинг."
|
||
)
|
||
generate_button = message.addButton("Сгенерировать скрипт", QMessageBox.ButtonRole.AcceptRole)
|
||
message.addButton("Продолжить редактирование", QMessageBox.ButtonRole.RejectRole)
|
||
message.setDefaultButton(generate_button)
|
||
message.exec()
|
||
return message.clickedButton() is generate_button
|
||
|
||
def _save_to(self, path: Path) -> None:
|
||
if len(self.document.lines) > MAX_LINES:
|
||
QMessageBox.warning(
|
||
self,
|
||
"Карта не сохранена",
|
||
f"Новая модель поддерживает максимум {MAX_LINES} линии. Сейчас в карте {len(self.document.lines)}.",
|
||
)
|
||
self.status.setText(f"Сохранение отменено: линий {len(self.document.lines)}/{MAX_LINES}")
|
||
self._refresh_ui_state()
|
||
return
|
||
active_line = self._get_line_or_none(self.active_line_id)
|
||
try:
|
||
self.document.save(path)
|
||
except Exception as exc:
|
||
self._restore_active_line(active_line)
|
||
self.canvas.refresh_styles()
|
||
self.refresh_tree()
|
||
self._on_selection_changed(self.canvas.selected_point_ids())
|
||
QMessageBox.critical(self, "Ошибка сохранения", str(exc))
|
||
self._refresh_ui_state()
|
||
return
|
||
self._dirty = False
|
||
self._undo_stack.clear()
|
||
self._redo_stack.clear()
|
||
self._restore_active_line(active_line)
|
||
self.canvas.refresh_styles()
|
||
self.refresh_tree()
|
||
self._on_selection_changed(self.canvas.selected_point_ids())
|
||
self.status.setText(f"Сохранено: {self._document_summary()}: {path}")
|
||
self._refresh_ui_state()
|
||
|
||
def add_line(self) -> None:
|
||
if len(self.document.lines) >= MAX_LINES:
|
||
QMessageBox.information(
|
||
self,
|
||
"Лимит линий",
|
||
f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.",
|
||
)
|
||
self.status.setText(f"Лимит модели: {MAX_LINES} линии. Сейчас: {len(self.document.lines)}")
|
||
self._refresh_ui_state()
|
||
return
|
||
self._push_undo()
|
||
line = self.document.add_line()
|
||
self._dirty = True
|
||
self.active_line_id = line.id
|
||
self.refresh_tree()
|
||
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
|
||
self._refresh_ui_state()
|
||
|
||
def add_block_from_selection(self) -> None:
|
||
line_id = self._require_active_line()
|
||
if line_id is None:
|
||
return
|
||
point_ids = self.canvas.selected_point_ids()
|
||
if point_ids and not self._confirm_reassignment(point_ids):
|
||
return
|
||
self._push_undo()
|
||
block = self.document.add_block(line_id, point_ids)
|
||
self._dirty = True
|
||
self.canvas.select_points(block.point_ids)
|
||
self._after_mapping_change(
|
||
f"Создан {block.name}: {len(block.point_ids)} LED. {self._unfolding_summary(line_id)}",
|
||
selection_already_updated=True,
|
||
)
|
||
|
||
def auto_blocks_from_selection(self) -> None:
|
||
line_id = self._require_active_line()
|
||
if line_id is None:
|
||
return
|
||
point_ids = self.canvas.selected_point_ids()
|
||
if not point_ids:
|
||
QMessageBox.information(self, "Нет выбора", "Выберите LED-точки для разбиения на блоки.")
|
||
return
|
||
preview = self.document.auto_block_preview(point_ids)
|
||
if not self._confirm_auto_blocks(point_ids, preview):
|
||
return
|
||
self._push_undo()
|
||
before = len(self.document.get_line(line_id).blocks)
|
||
self.document.auto_blocks_from_selection(line_id, point_ids, preview)
|
||
self._dirty = True
|
||
added = len(self.document.get_line(line_id).blocks) - before
|
||
self._after_mapping_change(
|
||
f"Развертка пересчитана по часовой стрелке: добавлено блоков {added}. {self._unfolding_summary(line_id)}"
|
||
)
|
||
|
||
def auto_block_from_point(self, point_id: int) -> None:
|
||
line_id = self.active_line_id
|
||
needs_new_line = line_id is None and not self.document.lines
|
||
if line_id is None and self.document.lines:
|
||
line_id = self.document.lines[0].id
|
||
self.active_line_id = line_id
|
||
if line_id is None and len(self.document.lines) >= MAX_LINES:
|
||
QMessageBox.information(
|
||
self,
|
||
"Лимит линий",
|
||
f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.",
|
||
)
|
||
return
|
||
preview = self.document.clockwise_block_from_seed_preview(point_id)
|
||
if not preview:
|
||
QMessageBox.information(self, "Нет блока", "Не удалось найти связанный блок от выбранной LED.")
|
||
return
|
||
if not self._confirm_reassignment(preview):
|
||
return
|
||
self._push_undo()
|
||
if needs_new_line:
|
||
line = self.document.add_line()
|
||
line_id = line.id
|
||
self.active_line_id = line_id
|
||
block = self.document.add_clockwise_block_from_group(line_id, point_id, preview)
|
||
self._dirty = True
|
||
self.canvas.select_points(block.point_ids)
|
||
self._after_mapping_change(
|
||
f"Автоматически добавлен {block.name}: {len(block.point_ids)} LED, стартовая точка {point_id}. "
|
||
f"{self._unfolding_summary(line_id)}",
|
||
selection_already_updated=True,
|
||
)
|
||
|
||
def auto_connect_active_line(self) -> None:
|
||
line_id = self._require_active_line()
|
||
if line_id is None:
|
||
return
|
||
self._push_undo()
|
||
self.document.auto_connect_line(line_id)
|
||
self._dirty = True
|
||
self._after_mapping_change(f"Развертка линии пересчитана по часовой стрелке. {self._unfolding_summary(line_id)}")
|
||
|
||
def rebuild_active_line_blocks(self) -> None:
|
||
line_id = self._require_active_line()
|
||
if line_id is None:
|
||
return
|
||
line = self.document.get_line(line_id)
|
||
if not line.blocks:
|
||
self.status.setText("В активной линии нет блоков для пересборки.")
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Пересобрать блоки",
|
||
"Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._push_undo()
|
||
block_count = self.document.rebuild_line_blocks_by_route(line_id)
|
||
self._dirty = True
|
||
self._after_mapping_change(f"Активная линия пересобрана: блоков {block_count}. {self._unfolding_summary(line_id)}")
|
||
|
||
def toggle_grid(self) -> None:
|
||
action = self.actions.get("grid")
|
||
self.canvas.set_grid_visible(action.isChecked() if action is not None else False)
|
||
|
||
def delete_current_assignment(self) -> None:
|
||
focus = self.focusWidget()
|
||
if focus is not None and (focus is self.tree or self.tree.isAncestorOf(focus)):
|
||
if self.remove_selected_tree_item():
|
||
return
|
||
self.clear_selected_point_assignments()
|
||
|
||
def clear_selected_point_assignments(self) -> None:
|
||
point_ids = self.canvas.selected_point_ids()
|
||
if not point_ids:
|
||
return
|
||
active_line = self._get_line_or_none(self.active_line_id)
|
||
self._push_undo()
|
||
removed = self.document.clear_point_assignments(point_ids)
|
||
if removed == 0:
|
||
self._undo_stack.pop()
|
||
self.status.setText("В выбранных LED нет фиксаций для удаления.")
|
||
self._refresh_ui_state()
|
||
return
|
||
self._dirty = True
|
||
self._restore_active_line(active_line)
|
||
self.canvas.select_points(point_ids)
|
||
self._after_mapping_change(f"Удалена фиксация LED: {removed}", selection_already_updated=True)
|
||
|
||
def remove_selected_tree_item(self) -> bool:
|
||
item = self.tree.currentItem()
|
||
if item is None:
|
||
return False
|
||
data = item.data(0, Qt.UserRole)
|
||
if not data:
|
||
return False
|
||
if data[0] == "line":
|
||
if not self._line_exists(data[1]):
|
||
self.refresh_tree()
|
||
return False
|
||
line = self.document.get_line(data[1])
|
||
affected = self._line_count(line)
|
||
if not self._confirm_delete("линию", line.name, affected):
|
||
return False
|
||
self._push_undo()
|
||
removed = self.document.remove_line(data[1])
|
||
self._dirty = True
|
||
self.active_line_id = self.document.lines[0].id if self.document.lines else None
|
||
self.canvas.select_points([])
|
||
self._after_mapping_change(f"Удалена линия и фиксация LED: {removed}", selection_already_updated=True)
|
||
return True
|
||
if data[0] == "block":
|
||
if not self._block_exists(data[1], data[2]):
|
||
self.refresh_tree()
|
||
return False
|
||
active_line = self._get_line_or_none(data[1])
|
||
block = self._find_block_or_none(data[1], data[2])
|
||
if block is None:
|
||
return False
|
||
if not self._confirm_delete("блок", block.name, len(block.point_ids)):
|
||
return False
|
||
self._push_undo()
|
||
removed = self.document.remove_block(data[1], data[2])
|
||
self._dirty = True
|
||
self._restore_active_line(active_line)
|
||
self.canvas.select_points([])
|
||
self._after_mapping_change(f"Удален блок и фиксация LED: {removed}", selection_already_updated=True)
|
||
return True
|
||
return False
|
||
|
||
def clear_all_assignments(self) -> None:
|
||
if not self.document.lines:
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Удалить все фиксации",
|
||
"Удалить все линии, блоки и назначенные индексы LED?",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._push_undo()
|
||
removed = self.document.clear_all_assignments()
|
||
self._dirty = True
|
||
self.active_line_id = None
|
||
self.canvas.select_points([])
|
||
self._after_mapping_change(f"Удалены все фиксации LED: {removed}", selection_already_updated=True)
|
||
|
||
def refresh_tree(self) -> None:
|
||
self.tree.setUpdatesEnabled(False)
|
||
self.tree.blockSignals(True)
|
||
try:
|
||
self.tree.clear()
|
||
self.block_append_buttons.clear()
|
||
for line in self.document.lines:
|
||
title = f"{line.name} ({self._line_count(line)} LED, блоков: {len(line.blocks)})"
|
||
line_item = QTreeWidgetItem([title])
|
||
line_item.setData(0, Qt.UserRole, ("line", line.id))
|
||
line_item.setForeground(0, QColor(line.color))
|
||
self.tree.addTopLevelItem(line_item)
|
||
line_offset = 0
|
||
for block in line.blocks:
|
||
index_to = line_offset + len(block.point_ids) - 1
|
||
index_range = "-" if not block.point_ids else f"{line_offset}-{index_to}"
|
||
block_item = QTreeWidgetItem([""])
|
||
block_item.setData(0, Qt.UserRole, ("block", line.id, block.id))
|
||
block_item.setSizeHint(0, QSize(0, 24))
|
||
line_item.addChild(block_item)
|
||
self._attach_block_widget(block_item, line.id, block.id, block.name, len(block.point_ids), index_range)
|
||
line_offset += len(block.point_ids)
|
||
line_item.setExpanded(True)
|
||
if line.id == self.active_line_id:
|
||
self.tree.setCurrentItem(line_item)
|
||
finally:
|
||
self.tree.blockSignals(False)
|
||
self.tree.setUpdatesEnabled(True)
|
||
self._refresh_block_append_buttons()
|
||
|
||
def _attach_block_widget(
|
||
self,
|
||
item: QTreeWidgetItem,
|
||
line_id: int,
|
||
block_id: int,
|
||
block_name: str,
|
||
led_count: int,
|
||
index_range: str,
|
||
) -> None:
|
||
widget = QWidget()
|
||
layout = QHBoxLayout(widget)
|
||
layout.setContentsMargins(16, 0, 2, 0)
|
||
layout.setSpacing(4)
|
||
label = QLabel(f"{block_name} ({led_count} LED, idx {index_range})")
|
||
label.setAlignment(Qt.AlignVCenter)
|
||
label.setMinimumWidth(0)
|
||
label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||
button = QPushButton()
|
||
button.setIcon(lucide_icon("plus"))
|
||
button.setFixedSize(22, 20)
|
||
button.setToolTip("Добавить выбранные LED в конец этого блока")
|
||
button.clicked.connect(lambda _checked=False, lid=line_id, bid=block_id: self.append_selection_to_block(lid, bid))
|
||
layout.addWidget(label, 1)
|
||
layout.addWidget(button, 0)
|
||
self.block_append_buttons[(line_id, block_id)] = button
|
||
self.tree.setItemWidget(item, 0, widget)
|
||
|
||
def append_selection_to_block(self, line_id: int, block_id: int) -> None:
|
||
block = self._find_block_or_none(line_id, block_id)
|
||
if block is None:
|
||
self.refresh_tree()
|
||
return
|
||
point_ids = self.canvas.selected_point_ids()
|
||
append_ids = [point_id for point_id in point_ids if point_id not in set(block.point_ids)]
|
||
if not append_ids:
|
||
self.status.setText("Выберите LED, которых еще нет в этом блоке.")
|
||
self._refresh_ui_state()
|
||
return
|
||
previous_active_line_id = self.active_line_id
|
||
self.active_line_id = line_id
|
||
if not self._confirm_reassignment(append_ids):
|
||
self.active_line_id = previous_active_line_id
|
||
self._refresh_ui_state()
|
||
return
|
||
self._push_undo()
|
||
added = self.document.append_points_to_block(line_id, block_id, append_ids)
|
||
if added == 0:
|
||
self._undo_stack.pop()
|
||
self.active_line_id = previous_active_line_id
|
||
self.status.setText("В выбранных LED нет новых точек для добавления в блок.")
|
||
self._refresh_ui_state()
|
||
return
|
||
self._dirty = True
|
||
self.active_line_id = line_id
|
||
self.canvas.select_points(append_ids)
|
||
updated_block = self._find_block_or_none(line_id, block_id)
|
||
block_name = block.name if updated_block is None else updated_block.name
|
||
self._after_mapping_change(
|
||
f"В {block_name} добавлено LED: {added}. {self._unfolding_summary(line_id)}",
|
||
selection_already_updated=True,
|
||
)
|
||
|
||
def toggle_brush_mode(self) -> None:
|
||
action = self.actions.get("brush")
|
||
enabled = action.isChecked() if action is not None else False
|
||
self.brush_target = self._initial_brush_target()
|
||
self._brush_stroke_started = False
|
||
self.canvas.set_brush_enabled(enabled)
|
||
self._sync_brush_preview_exclusions()
|
||
if enabled and self.brush_target is not None:
|
||
line_id, block_id = self.brush_target
|
||
block = self._find_block_or_none(line_id, block_id)
|
||
block_name = "блок" if block is None else block.name
|
||
self.status.setText(f"Кисть включена: рисуйте продолжение для {block_name}.")
|
||
elif enabled:
|
||
self.status.setText("Кисть включена: начните рисовать, чтобы создать новый блок.")
|
||
else:
|
||
self.status.setText("Кисть выключена.")
|
||
self._refresh_ui_state()
|
||
|
||
def add_brush_point_to_block(self, point_id: int) -> None:
|
||
target = self.brush_target or self._initial_brush_target()
|
||
if target is None:
|
||
if not self._brush_stroke_started:
|
||
self._push_undo()
|
||
self._brush_stroke_started = True
|
||
target = self._create_brush_target(point_id)
|
||
if target is None:
|
||
if self._undo_stack:
|
||
self._undo_stack.pop()
|
||
self._brush_stroke_started = False
|
||
self._refresh_ui_state()
|
||
return
|
||
line_id, block_id = target
|
||
block = self._find_block_or_none(line_id, block_id)
|
||
if block is None:
|
||
self._disable_brush("Целевой блок больше не существует.")
|
||
self.refresh_tree()
|
||
return
|
||
if point_id in block.point_ids or point_id in self._brush_pending_point_set:
|
||
self.active_line_id = line_id
|
||
self.brush_target = (line_id, block_id)
|
||
self._sync_brush_preview_exclusions()
|
||
return
|
||
if not self._brush_stroke_started:
|
||
self._push_undo()
|
||
self._brush_stroke_started = True
|
||
self.active_line_id = line_id
|
||
self.brush_target = (line_id, block_id)
|
||
self._brush_pending_point_ids.append(point_id)
|
||
self._brush_pending_point_set.add(point_id)
|
||
self._sync_brush_preview_exclusions()
|
||
self.status.setText(f"Кисть: добавлено в мазок LED {len(self._brush_pending_point_ids)}.")
|
||
|
||
def finish_brush_stroke(self) -> None:
|
||
pending_ids = self._brush_pending_point_ids[:]
|
||
self._brush_pending_point_ids.clear()
|
||
self._brush_pending_point_set.clear()
|
||
self._brush_stroke_started = False
|
||
if not pending_ids:
|
||
self._sync_brush_preview_exclusions()
|
||
return
|
||
target = self.brush_target
|
||
if target is None:
|
||
if self._undo_stack:
|
||
self._restore_state(self._undo_stack.pop())
|
||
self._sync_brush_preview_exclusions()
|
||
self._refresh_ui_state()
|
||
return
|
||
line_id, block_id = target
|
||
block = self._find_block_or_none(line_id, block_id)
|
||
if block is None:
|
||
if self._undo_stack:
|
||
self._restore_state(self._undo_stack.pop())
|
||
self._disable_brush("Целевой блок больше не существует.")
|
||
self.refresh_tree()
|
||
return
|
||
previous_active_line_id = self.active_line_id
|
||
self.active_line_id = line_id
|
||
if not self._confirm_reassignment(pending_ids):
|
||
if self._undo_stack:
|
||
self._restore_state(self._undo_stack.pop())
|
||
self.active_line_id = previous_active_line_id
|
||
self.canvas.select_points(pending_ids)
|
||
self._sync_brush_preview_exclusions()
|
||
self._refresh_ui_state()
|
||
return
|
||
added = self.document.append_points_to_block(line_id, block_id, pending_ids)
|
||
if added == 0:
|
||
if self._undo_stack:
|
||
self._restore_state(self._undo_stack.pop())
|
||
self.active_line_id = previous_active_line_id
|
||
self._sync_brush_preview_exclusions()
|
||
self._refresh_ui_state()
|
||
return
|
||
self._dirty = True
|
||
self.active_line_id = line_id
|
||
self.canvas.select_points(pending_ids)
|
||
self._sync_brush_preview_exclusions()
|
||
self._after_mapping_change(f"Кисть: в {block.name} добавлено LED: {added}.", selection_already_updated=True)
|
||
|
||
def _disable_brush(self, message: str) -> None:
|
||
action = self.actions.get("brush")
|
||
if action is not None:
|
||
action.setChecked(False)
|
||
self.canvas.set_brush_enabled(False)
|
||
self._brush_stroke_started = False
|
||
self._brush_pending_point_ids.clear()
|
||
self._brush_pending_point_set.clear()
|
||
self._sync_brush_preview_exclusions()
|
||
self.status.setText(message)
|
||
self._refresh_ui_state()
|
||
|
||
def _on_tree_selection(self) -> None:
|
||
item = self.tree.currentItem()
|
||
if item is None:
|
||
return
|
||
data = item.data(0, Qt.UserRole)
|
||
if not data:
|
||
return
|
||
if data[0] == "line":
|
||
line = self._get_line_or_none(data[1])
|
||
if line is None:
|
||
self.refresh_tree()
|
||
return
|
||
self.active_line_id = line.id
|
||
self.brush_target = None
|
||
self.canvas.set_active_line(self.active_line_id)
|
||
self.canvas.select_points([point_id for block in line.blocks for point_id in block.point_ids])
|
||
elif data[0] == "block":
|
||
block = self._find_block_or_none(data[1], data[2])
|
||
if block is None:
|
||
self.refresh_tree()
|
||
return
|
||
self.active_line_id = data[1]
|
||
self.brush_target = (data[1], data[2])
|
||
self.canvas.set_active_line(self.active_line_id)
|
||
self.canvas.select_points(block.point_ids)
|
||
self._sync_brush_preview_exclusions()
|
||
|
||
def _show_tree_menu(self, pos) -> None: # noqa: ANN001
|
||
item = self.tree.itemAt(pos)
|
||
if item is not None:
|
||
self.tree.setCurrentItem(item)
|
||
menu = QMenu(self)
|
||
label = "Удалить выбранную фиксацию"
|
||
if item is not None:
|
||
data = item.data(0, Qt.UserRole)
|
||
if data and data[0] == "line":
|
||
label = "Удалить линию"
|
||
elif data and data[0] == "block":
|
||
label = "Удалить блок"
|
||
delete_action = menu.addAction(label)
|
||
delete_action.setEnabled(item is not None)
|
||
clear_all_action = menu.addAction("Удалить все фиксации")
|
||
clear_all_action.setEnabled(bool(self.document.lines))
|
||
fit_action = menu.addAction("Показать все точки")
|
||
fit_action.setEnabled(bool(self.document.points))
|
||
chosen = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||
if chosen == delete_action:
|
||
self.remove_selected_tree_item()
|
||
elif chosen == clear_all_action:
|
||
self.clear_all_assignments()
|
||
elif chosen == fit_action:
|
||
self.canvas.fit_to_content()
|
||
|
||
def _show_table_menu(self, pos) -> None: # noqa: ANN001
|
||
item = self.table.itemAt(pos)
|
||
if item is None:
|
||
self.table.clearSelection()
|
||
self.canvas.select_points([])
|
||
elif item.row() not in self._selected_table_rows():
|
||
self.table.selectRow(item.row())
|
||
menu = QMenu(self)
|
||
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
|
||
delete_action.setEnabled(self._selected_table_has_assignment())
|
||
clear_selection_action = menu.addAction("Очистить выбор")
|
||
clear_selection_action.setEnabled(bool(self._selected_table_point_ids()))
|
||
chosen = menu.exec(self.table.viewport().mapToGlobal(pos))
|
||
if chosen == delete_action:
|
||
self.canvas.select_points(self._selected_table_point_ids())
|
||
self.clear_selected_point_assignments()
|
||
elif chosen == clear_selection_action:
|
||
self.canvas.select_points([])
|
||
|
||
def _on_selection_changed(self, point_ids: list[int]) -> None:
|
||
self._updating_table = True
|
||
sort_column = self.table.horizontalHeader().sortIndicatorSection()
|
||
sort_order = self.table.horizontalHeader().sortIndicatorOrder()
|
||
sorting_enabled = self.table.isSortingEnabled()
|
||
self.table.setUpdatesEnabled(False)
|
||
self.table.setSortingEnabled(False)
|
||
self.table.blockSignals(True)
|
||
try:
|
||
self.table.setRowCount(len(point_ids))
|
||
for row, point_id in enumerate(point_ids):
|
||
point = self.document.get_point(point_id)
|
||
values = [
|
||
point.uid,
|
||
point.source_id,
|
||
point.row,
|
||
point.col,
|
||
"-" if point.line_id is None else point.line_id,
|
||
"-" if point.block_id is None else point.block_id,
|
||
]
|
||
values.extend(self._line_index_columns(point.line_id, point.line_index))
|
||
for col, value in enumerate(values):
|
||
item = SortableTableItem(str(value))
|
||
item.setData(Qt.UserRole, point.uid)
|
||
item.setData(SORT_ROLE, self._table_sort_value(value))
|
||
self.table.setItem(row, col, item)
|
||
self._update_line_index_column_visibility(point_ids)
|
||
self.table.setSortingEnabled(sorting_enabled)
|
||
if sorting_enabled and sort_column >= 0:
|
||
self.table.sortItems(sort_column, sort_order)
|
||
finally:
|
||
self.table.blockSignals(False)
|
||
if self.table.isSortingEnabled() != sorting_enabled:
|
||
self.table.setSortingEnabled(sorting_enabled)
|
||
self.table.setUpdatesEnabled(True)
|
||
self._updating_table = False
|
||
self.selection_label.setText(self._selection_summary(point_ids))
|
||
self.status.setText(self._selection_summary(point_ids))
|
||
self._refresh_ui_state()
|
||
|
||
def _select_rows_on_canvas(self) -> None:
|
||
if self._updating_table:
|
||
return
|
||
ids = []
|
||
for item in self.table.selectedItems():
|
||
if item.column() == 0:
|
||
ids.append(int(item.data(Qt.UserRole)))
|
||
if ids:
|
||
self.canvas.select_points(ids)
|
||
else:
|
||
self.canvas.select_points([])
|
||
|
||
def _require_active_line(self) -> int | None:
|
||
if self.active_line_id is None:
|
||
self.status.setText("Создайте или выберите линию перед назначением LED.")
|
||
self._refresh_ui_state()
|
||
return None
|
||
return self.active_line_id
|
||
|
||
def _after_mapping_change(self, message: str, *, selection_already_updated: bool = False) -> None:
|
||
self.canvas.refresh_styles()
|
||
self.refresh_tree()
|
||
if not selection_already_updated:
|
||
self._on_selection_changed(self.canvas.selected_point_ids())
|
||
self.status.setText(message)
|
||
self._refresh_ui_state()
|
||
|
||
def undo_last_change(self) -> None:
|
||
if not self._undo_stack:
|
||
self.status.setText("Нет действий для отмены.")
|
||
self._refresh_ui_state()
|
||
return
|
||
self._redo_stack.append(self._capture_state())
|
||
self._restore_state(self._undo_stack.pop())
|
||
self.status.setText("Последнее изменение отменено.")
|
||
self._refresh_ui_state()
|
||
|
||
def redo_last_change(self) -> None:
|
||
if not self._redo_stack:
|
||
self.status.setText("Нет действий для повтора.")
|
||
self._refresh_ui_state()
|
||
return
|
||
self._undo_stack.append(self._capture_state())
|
||
if len(self._undo_stack) > 20:
|
||
self._undo_stack.pop(0)
|
||
self._restore_state(self._redo_stack.pop())
|
||
self.status.setText("Изменение повторено.")
|
||
self._refresh_ui_state()
|
||
|
||
def _sync_canvas_to_active_line(self) -> None:
|
||
line = self._get_line_or_none(self.active_line_id)
|
||
if line is None:
|
||
self.canvas.set_active_line(None)
|
||
self.canvas.select_points([])
|
||
return
|
||
self.canvas.set_active_line(self.active_line_id)
|
||
self.canvas.select_points([point_id for block in line.blocks for point_id in block.point_ids])
|
||
|
||
def _restore_active_line(self, line: LedLine | None) -> None:
|
||
if line is not None and line in self.document.lines:
|
||
self.active_line_id = line.id
|
||
elif self.active_line_id is not None and self._get_line_or_none(self.active_line_id) is not None:
|
||
return
|
||
else:
|
||
self.active_line_id = self.document.lines[0].id if self.document.lines else None
|
||
|
||
def _selected_table_point_ids(self) -> list[int]:
|
||
rows = sorted({item.row() for item in self.table.selectedItems()})
|
||
point_ids: list[int] = []
|
||
for row in rows:
|
||
item = self.table.item(row, 0)
|
||
if item is not None:
|
||
point_ids.append(int(item.data(Qt.UserRole)))
|
||
return point_ids
|
||
|
||
def _selected_table_rows(self) -> set[int]:
|
||
return {item.row() for item in self.table.selectedItems()}
|
||
|
||
def _selected_table_has_assignment(self) -> bool:
|
||
for point_id in self._selected_table_point_ids():
|
||
point = self.document.get_point(point_id)
|
||
if point.line_id is not None or point.block_id is not None:
|
||
return True
|
||
return False
|
||
|
||
def _find_block(self, line_id: int, block_id: int) -> LedBlock:
|
||
line = self.document.get_line(line_id)
|
||
for block in line.blocks:
|
||
if block.id == block_id:
|
||
return block
|
||
raise KeyError(block_id)
|
||
|
||
def _find_block_or_none(self, line_id: int, block_id: int) -> LedBlock | None:
|
||
line = self._get_line_or_none(line_id)
|
||
if line is None:
|
||
return None
|
||
for block in line.blocks:
|
||
if block.id == block_id:
|
||
return block
|
||
return None
|
||
|
||
def _get_line_or_none(self, line_id: int) -> LedLine | None:
|
||
for line in self.document.lines:
|
||
if line.id == line_id:
|
||
return line
|
||
return None
|
||
|
||
def _line_exists(self, line_id: int) -> bool:
|
||
return self._get_line_or_none(line_id) is not None
|
||
|
||
def _block_exists(self, line_id: int, block_id: int) -> bool:
|
||
return self._find_block_or_none(line_id, block_id) is not None
|
||
|
||
def _refresh_ui_state(self) -> None:
|
||
point_ids = self.canvas.selected_point_ids()
|
||
has_points = bool(self.document.points)
|
||
has_lines = bool(self.document.lines)
|
||
has_active_line = self._get_line_or_none(self.active_line_id) is not None
|
||
has_selection = bool(point_ids)
|
||
selected_has_assignment = any(
|
||
self.document.get_point(point_id).line_id is not None
|
||
or self.document.get_point(point_id).block_id is not None
|
||
for point_id in point_ids
|
||
)
|
||
assigned_count = sum(1 for point in self.document.points if point.line_id is not None)
|
||
block_count = sum(len(line.blocks) for line in self.document.lines)
|
||
file_name = "-" if self.document.path is None else self.document.path.name
|
||
dirty_mark = "*" if self._dirty else ""
|
||
self.summary_label.setText(
|
||
f"Файл: {file_name}{dirty_mark} | Точек: {len(self.document.points)} | "
|
||
f"Линий: {len(self.document.lines)}/{MAX_LINES} | "
|
||
f"Блоков: {block_count} | Назначено: {assigned_count}"
|
||
)
|
||
active_line = self._get_line_or_none(self.active_line_id)
|
||
if active_line is None:
|
||
self.active_label.setText("Активная линия: -")
|
||
self.active_label.setStyleSheet("")
|
||
else:
|
||
self.active_label.setText(f"Активная линия: {active_line.name}")
|
||
self.active_label.setStyleSheet(
|
||
f"border-left: 8px solid {active_line.color}; padding-left: 6px; font-weight: 600;"
|
||
)
|
||
self.canvas.set_active_line(self.active_line_id if active_line is not None else None)
|
||
self.selection_label.setText(self._selection_summary(point_ids))
|
||
|
||
state = {
|
||
"save": has_points and self._dirty,
|
||
"save_as": has_points,
|
||
"generate_script": has_points,
|
||
"undo": bool(self._undo_stack),
|
||
"redo": bool(self._redo_stack),
|
||
"add_line": has_points and len(self.document.lines) < MAX_LINES,
|
||
"add_block": has_points and has_active_line,
|
||
"auto_blocks": has_points and has_active_line and has_selection,
|
||
"clockwise": has_active_line,
|
||
"rebuild_route": has_active_line,
|
||
"brush": has_points,
|
||
"delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(),
|
||
"clear_all": has_lines,
|
||
"clear_selection": has_selection,
|
||
"fit_view": has_points,
|
||
"grid": True,
|
||
"toggle_sidebar": True,
|
||
}
|
||
for key, enabled in state.items():
|
||
action = self.actions.get(key)
|
||
if action is not None:
|
||
action.setEnabled(enabled)
|
||
button = self.side_buttons.get(key)
|
||
if button is not None:
|
||
button.setEnabled(enabled)
|
||
brush_action = self.actions.get("brush")
|
||
if brush_action is not None and not state["brush"] and brush_action.isChecked():
|
||
brush_action.setChecked(False)
|
||
self.canvas.set_brush_enabled(False)
|
||
self._brush_stroke_started = False
|
||
self._refresh_block_append_buttons(point_ids)
|
||
self._refresh_window_title()
|
||
|
||
def _refresh_block_append_buttons(self, point_ids: list[int] | None = None) -> None:
|
||
if point_ids is None:
|
||
point_ids = self.canvas.selected_point_ids()
|
||
selected_set = set(point_ids)
|
||
has_selection = bool(selected_set)
|
||
for (line_id, block_id), button in self.block_append_buttons.items():
|
||
block = self._find_block_or_none(line_id, block_id)
|
||
block_point_set = set() if block is None else set(block.point_ids)
|
||
button.setEnabled(
|
||
block is not None
|
||
and has_selection
|
||
and not selected_set.issubset(block_point_set)
|
||
)
|
||
|
||
def _refresh_window_title(self) -> None:
|
||
file_name = "без файла" if self.document.path is None else self.document.path.name
|
||
dirty_mark = "*" if self._dirty else ""
|
||
self.setWindowTitle(f"{dirty_mark}{file_name} - LED Mapper Editor")
|
||
|
||
def _tree_has_focus_with_item(self) -> bool:
|
||
focus = self.focusWidget()
|
||
return (
|
||
focus is not None
|
||
and (focus is self.tree or self.tree.isAncestorOf(focus))
|
||
and self.tree.currentItem() is not None
|
||
)
|
||
|
||
def _current_block_target(self) -> tuple[int, int] | None:
|
||
if self.brush_target is not None and self._find_block_or_none(*self.brush_target) is not None:
|
||
return self.brush_target
|
||
item = self.tree.currentItem()
|
||
if item is None:
|
||
return None
|
||
data = item.data(0, Qt.UserRole)
|
||
if data and data[0] == "block" and self._find_block_or_none(data[1], data[2]) is not None:
|
||
return data[1], data[2]
|
||
return None
|
||
|
||
def _selected_block_target(self) -> tuple[int, int] | None:
|
||
for point_id in self.canvas.selected_point_ids():
|
||
point = self.document.get_point(point_id)
|
||
if point.line_id is None or point.block_id is None:
|
||
continue
|
||
if self._find_block_or_none(point.line_id, point.block_id) is not None:
|
||
return point.line_id, point.block_id
|
||
return None
|
||
|
||
def _initial_brush_target(self) -> tuple[int, int] | None:
|
||
return self._selected_block_target() or self._current_block_target()
|
||
|
||
def _create_brush_target(self, point_id: int) -> tuple[int, int] | None:
|
||
line = self._get_line_or_none(self.active_line_id)
|
||
if line is None:
|
||
point = self.document.get_point(point_id)
|
||
line = self._get_line_or_none(point.line_id) if point.line_id is not None else None
|
||
if line is None and self.document.lines:
|
||
line = self.document.lines[0]
|
||
if line is None:
|
||
if len(self.document.lines) >= MAX_LINES:
|
||
self.status.setText(f"Нельзя создать блок: лимит линий {MAX_LINES}.")
|
||
return None
|
||
line = self.document.add_line()
|
||
block = self.document.add_block(line.id, [])
|
||
self.active_line_id = line.id
|
||
self.brush_target = (line.id, block.id)
|
||
self._sync_brush_preview_exclusions()
|
||
self.status.setText(f"Кисть: создан {block.name}, добавляйте LED.")
|
||
return self.brush_target
|
||
|
||
def _sync_brush_preview_exclusions(self) -> None:
|
||
excluded_ids = set(self._brush_pending_point_ids)
|
||
target = self.brush_target
|
||
if target is not None:
|
||
block = self._find_block_or_none(*target)
|
||
if block is not None:
|
||
excluded_ids.update(block.point_ids)
|
||
self.canvas.set_brush_excluded_point_ids(excluded_ids)
|
||
|
||
def _push_undo(self) -> None:
|
||
self._undo_stack.append(self._capture_state())
|
||
if len(self._undo_stack) > 20:
|
||
self._undo_stack.pop(0)
|
||
self._redo_stack.clear()
|
||
self._refresh_ui_state()
|
||
|
||
def _capture_state(self) -> tuple[MappingDocument, int | None, list[int], bool]:
|
||
return (
|
||
deepcopy(self.document),
|
||
self.active_line_id,
|
||
self.canvas.selected_point_ids(),
|
||
self._dirty,
|
||
)
|
||
|
||
def _restore_state(self, state: tuple[MappingDocument, int | None, list[int], bool]) -> None:
|
||
document, active_line_id, selected_point_ids, dirty = state
|
||
self.document = document
|
||
self.active_line_id = active_line_id
|
||
self._dirty = dirty
|
||
self.canvas.set_document(self.document, reset_view=False)
|
||
self.canvas.set_active_line(self.active_line_id)
|
||
self.refresh_tree()
|
||
self.canvas.select_points([point_id for point_id in selected_point_ids if 0 <= point_id < len(self.document.points)])
|
||
|
||
def _selection_summary(self, point_ids: list[int]) -> str:
|
||
if not point_ids:
|
||
return "Выбранные LED: 0"
|
||
assigned = 0
|
||
lines: set[int] = set()
|
||
for point_id in point_ids:
|
||
point = self.document.get_point(point_id)
|
||
if point.line_id is not None:
|
||
assigned += 1
|
||
lines.add(point.line_id)
|
||
unassigned = len(point_ids) - assigned
|
||
line_text = "-" if not lines else ", ".join(str(line_id) for line_id in sorted(lines))
|
||
return f"Выбранные LED: {len(point_ids)} | назначено: {assigned} | свободно: {unassigned} | линии: {line_text}"
|
||
|
||
def _confirm_reassignment(self, point_ids: list[int]) -> bool:
|
||
foreign_points = [
|
||
point_id
|
||
for point_id in point_ids
|
||
if self.document.get_point(point_id).line_id is not None
|
||
and self.document.get_point(point_id).line_id != self.active_line_id
|
||
]
|
||
assigned_points = [
|
||
point_id
|
||
for point_id in point_ids
|
||
if self.document.get_point(point_id).line_id is not None
|
||
or self.document.get_point(point_id).block_id is not None
|
||
]
|
||
if not foreign_points and not assigned_points:
|
||
return True
|
||
if assigned_points and not foreign_points:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Перезаписать блок",
|
||
f"{len(assigned_points)} выбранных LED уже находятся в блоках. Перезаписать их новым блоком?",
|
||
)
|
||
return answer == QMessageBox.StandardButton.Yes
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Переназначить LED",
|
||
f"{len(foreign_points)} выбранных LED уже находятся в других линиях. Переназначить их в активную линию?",
|
||
)
|
||
return answer == QMessageBox.StandardButton.Yes
|
||
|
||
def closeEvent(self, event) -> None: # noqa: ANN001
|
||
if self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
|
||
event.accept()
|
||
else:
|
||
event.ignore()
|
||
|
||
def _confirm_auto_blocks(self, point_ids: list[int], preview: list[list[int]]) -> bool:
|
||
if not preview:
|
||
return False
|
||
assigned = sum(1 for point_id in point_ids if self.document.get_point(point_id).line_id is not None)
|
||
if assigned == 0 and len(preview) == 1:
|
||
return True
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Создать автоблоки",
|
||
f"Будет создано блоков: {len(preview)}, LED: {sum(len(group) for group in preview)}. "
|
||
f"Уже назначенных LED в выборе: {assigned}. Продолжить?",
|
||
)
|
||
return answer == QMessageBox.StandardButton.Yes
|
||
|
||
def _confirm_delete(self, kind: str, name: str, affected: int) -> bool:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Подтвердите удаление",
|
||
f"Удалить {kind} '{name}' и снять фиксацию с {affected} LED?",
|
||
)
|
||
return answer == QMessageBox.StandardButton.Yes
|
||
|
||
def _confirm_discard_changes(self, message: str | None = None) -> bool:
|
||
if not self._dirty:
|
||
return True
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Есть несохраненные изменения",
|
||
message or "Открыть другой файл и потерять несохраненные изменения текущей карты?",
|
||
)
|
||
return answer == QMessageBox.StandardButton.Yes
|
||
|
||
@staticmethod
|
||
def _line_count(line: LedLine) -> int:
|
||
return sum(len(block.point_ids) for block in line.blocks)
|
||
|
||
@staticmethod
|
||
def _line_index_columns(line_id: int | None, line_index: int | None) -> list[str]:
|
||
return [
|
||
"-" if line_id != column or line_index is None else str(line_index)
|
||
for column in range(1, MAX_LINES + 1)
|
||
]
|
||
|
||
def _update_line_index_column_visibility(self, point_ids: list[int]) -> None:
|
||
visible_line_ids = {
|
||
point.line_id
|
||
for point_id in point_ids
|
||
for point in [self.document.get_point(point_id)]
|
||
if point.line_id is not None and point.line_index is not None
|
||
}
|
||
for line_id in range(1, MAX_LINES + 1):
|
||
self.table.setColumnHidden(5 + line_id, line_id not in visible_line_ids)
|
||
|
||
@staticmethod
|
||
def _table_sort_value(value) -> tuple[int, int | str]: # noqa: ANN001
|
||
if value == "-":
|
||
return 1, 0
|
||
if isinstance(value, int):
|
||
return 0, value
|
||
text = str(value)
|
||
if text.lstrip("-").isdigit():
|
||
return 0, int(text)
|
||
return 0, text
|
||
|
||
def _line_summary(self, line: LedLine) -> str:
|
||
return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}"
|
||
|
||
def _unfolding_summary(self, line_id: int) -> str:
|
||
line = self.document.get_line(line_id)
|
||
count = self._line_count(line)
|
||
index_range = "-" if count == 0 else f"0-{count - 1}"
|
||
return f"{line.name}: развертка {len(line.blocks)} блоков, {count} LED, индекс {index_range}"
|
||
|
||
def _document_summary(self) -> str:
|
||
block_count = sum(len(line.blocks) for line in self.document.lines)
|
||
assigned_count = sum(1 for point in self.document.points if point.line_id is not None)
|
||
return f"линий: {len(self.document.lines)}/{MAX_LINES}, блоков: {block_count}, назначено LED: {assigned_count}"
|