from __future__ import annotations from copy import deepcopy from pathlib import Path import sys from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence from PySide6.QtWidgets import ( QAbstractItemView, QAbstractScrollArea, QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, QInputDialog, QHBoxLayout, QHeaderView, QLabel, QMainWindow, QMenu, QMessageBox, QPushButton, QSizePolicy, QSplitter, QStackedWidget, QTableWidget, QTableWidgetItem, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, QKeySequenceEdit, QLineEdit, QPlainTextEdit, QTextEdit, ) try: from .background_dialog import BackgroundDialog from .canvas import LedCanvas from .icons import lucide_icon from .localization import ( ask_question, localize_dialog_buttons, localize_message_box, show_critical, show_information, show_warning, ) from .models import LedBlock, LedLine, MappingDocument from .panel_mapping_generator import generate_panel_mapping_from_document from .settings import DEFAULT_SHORTCUTS, AppSettings from .settings_dialog import SettingsDialog from .theme import apply_app_theme, effective_theme from .unfolding import MAX_LINES from .mapping_renderer import render_mapping_image except ImportError: from background_dialog import BackgroundDialog from canvas import LedCanvas from icons import lucide_icon from localization import ( ask_question, localize_dialog_buttons, localize_message_box, show_critical, show_information, show_warning, ) from models import LedBlock, LedLine, MappingDocument from panel_mapping_generator import generate_panel_mapping_from_document from settings import DEFAULT_SHORTCUTS, AppSettings from settings_dialog import SettingsDialog from theme import apply_app_theme, effective_theme from unfolding import MAX_LINES from mapping_renderer import render_mapping_image ROOT_DIR = Path(__file__).resolve().parents[1] APP_DIR = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else ROOT_DIR SORT_ROLE = Qt.UserRole + 1 SIDEBAR_DEFAULT_WIDTH = 260 SIDEBAR_MIN_WIDTH = 180 SIDEBAR_COLLAPSED_WIDTH = 34 MAX_WIDGET_WIDTH = 16777215 ID_COLUMN = 0 X_COLUMN = 1 Y_COLUMN = 2 ROW_COLUMN = 3 COL_COLUMN = 4 LINE_COLUMN = 5 BLOCK_COLUMN = 6 UID_COLUMN = 7 _NATIVE_VIRTUAL_KEY_NAMES = { 0x08: "Backspace", 0x09: "Tab", 0x0D: "Return", 0x1B: "Esc", 0x2E: "Del", 0xBC: ",", } _NATIVE_VIRTUAL_KEY_NAMES.update({0x30 + index: str(index) for index in range(10)}) _NATIVE_VIRTUAL_KEY_NAMES.update({0x41 + index: chr(0x41 + index) for index in range(26)}) _NATIVE_VIRTUAL_KEY_NAMES.update({0x70 + index: f"F{index + 1}" for index in range(24)}) def physical_key_name(event) -> str: # noqa: ANN001 native_key = event.nativeVirtualKey() if native_key in _NATIVE_VIRTUAL_KEY_NAMES: return _NATIVE_VIRTUAL_KEY_NAMES[native_key] key = event.key() if Qt.Key_A <= key <= Qt.Key_Z: return chr(ord("A") + key - Qt.Key_A) if Qt.Key_0 <= key <= Qt.Key_9: return chr(ord("0") + key - Qt.Key_0) if Qt.Key_F1 <= key <= Qt.Key_F24: return f"F{key - Qt.Key_F1 + 1}" if key == Qt.Key_Delete: return "Del" if key == Qt.Key_Escape: return "Esc" if key == Qt.Key_Comma: return "," return QKeySequence(key).toString(QKeySequence.SequenceFormat.PortableText) 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 GenerationStrategyDialog(QDialog): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setWindowTitle("Выбор стратегии генерации") self.setMinimumWidth(360) layout = QVBoxLayout(self) label = QLabel("Выберите элементы для включения в сгенерированный скрипт:") layout.addWidget(label) self.grid_cb = QCheckBox("Сетка светодиодов (виртуальная матрица)") self.grid_cb.setChecked(True) layout.addWidget(self.grid_cb) self.blocks_cb = QCheckBox("Блоки светодиодов (подсветка по id и количеству)") self.blocks_cb.setChecked(True) layout.addWidget(self.blocks_cb) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) localize_dialog_buttons(buttons) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) self.ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok) self.grid_cb.toggled.connect(self.validate) self.blocks_cb.toggled.connect(self.validate) def validate(self) -> None: if not self.grid_cb.isChecked() and not self.blocks_cb.isChecked(): self.ok_button.setEnabled(False) else: self.ok_button.setEnabled(True) 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._pending_background_undo_state: tuple[MappingDocument, int | None, list[int], bool] | None = None self.settings = AppSettings.load() apply_app_theme(self.settings.theme) self.actions: dict[str, QAction] = {} self.action_icon_names: dict[str, str] = {} self.shortcut_action_keys: set[str] = set() self.side_buttons: dict[str, QPushButton] = {} self.side_button_icon_names: dict[str, str] = {} self.block_append_buttons: dict[tuple[int, int], QPushButton] = {} self.brush_target: tuple[int, int] | None = None self._brush_stroke_started = False self._brush_append_to_selected = False self._brush_pending_point_ids: list[int] = [] self._brush_pending_point_set: set[int] = set() self.sidebar_splitter: QSplitter | None = None self.sidebar_content_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._apply_canvas_settings() self.canvas.selectionChanged.connect(self._on_selection_changed) self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments) self.canvas.deletePointsRequested.connect(self.delete_selected_points) self.canvas.editPointRequested.connect(self.edit_point_coordinates) self.canvas.pointMoveFinished.connect(self.move_point_from_canvas) self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point) self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise) self.canvas.addLedRequested.connect(self.add_manual_led) self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) self.canvas.brushPointTouched.connect(self.add_brush_point_to_block) self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke) self.canvas.backgroundEditStarted.connect(self._on_background_edit_started) self.canvas.backgroundChanged.connect(self._on_background_changed) self.canvas.backgroundEditFinished.connect(self._on_background_edit_finished) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Линии и блоки"]) self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.itemSelectionChanged.connect(self._on_tree_selection) self.tree.customContextMenuRequested.connect(self._show_tree_menu) self.tree.itemDoubleClicked.connect(self._on_tree_item_double_clicked) self.tree_rename_action = QAction(self) self.tree_rename_action.setShortcut(QKeySequence("F2")) self.tree_rename_action.setShortcutContext(Qt.WidgetShortcut) self.tree_rename_action.triggered.connect(self.rename_selected_tree_item) self.tree.addAction(self.tree_rename_action) self.actions["tree_rename"] = self.tree_rename_action self.tree_delete_action = QAction(self) self.tree_delete_action.setShortcut(QKeySequence("Ctrl+Del")) self.tree_delete_action.setShortcutContext(Qt.WidgetShortcut) self.tree_delete_action.triggered.connect(self.remove_selected_tree_item) self.tree.addAction(self.tree_delete_action) self.actions["tree_delete"] = self.tree_delete_action self.tree_merge_action = QAction(self) self.tree_merge_action.setShortcut(QKeySequence("Ctrl+M")) self.tree_merge_action.setShortcutContext(Qt.WidgetShortcut) self.tree_merge_action.triggered.connect(self.merge_selected_blocks) self.tree.addAction(self.tree_merge_action) self.actions["tree_merge"] = self.tree_merge_action self.table = QTableWidget(0, 8) self.table.setHorizontalHeaderLabels( ["ID", "X", "Y", "Ряд", "Кол.", "Линия", "Блок", "UID"] ) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) self.table.horizontalHeader().setMinimumSectionSize(24) self.table.horizontalHeader().setStretchLastSection(False) for column, width in enumerate((48, 72, 72, 54, 54, 58, 58, 54)): 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.DoubleClicked | QAbstractItemView.EditKeyPressed) self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.horizontalHeader().setSortIndicator(UID_COLUMN, Qt.AscendingOrder) self.table.setSortingEnabled(True) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.itemSelectionChanged.connect(self._select_rows_on_canvas) self.table.itemChanged.connect(self._on_table_item_changed) 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(self._on_application_focus_changed) application.installEventFilter(self) 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: self.action_icon_names[key] = icon_name action.setIcon(lucide_icon(icon_name, self._icon_color())) if shortcut is not None or key in DEFAULT_SHORTCUTS: action.setShortcut(self._shortcut_sequence(key, shortcut or QKeySequence())) action.setShortcutContext(Qt.ApplicationShortcut) self.shortcut_action_keys.add(key) if tooltip is not None: action.setToolTip(tooltip) action.setStatusTip(tooltip) action.triggered.connect(handler) self.actions[key] = action return action add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New) 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( "export_image", "Экспортировать в изображение...", self.export_mapping_image, "camera", QKeySequence("Ctrl+Shift+E"), "Экспортировать текущую схему маппинга в изображение (PNG, JPEG)" ) add_action("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,")) 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_led_action = add_action("add_led", "Новый светодиод", self.toggle_add_led_mode, "plus", QKeySequence("N"), "Добавить свободный LED на схему") add_led_action.setCheckable(True) 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( "counterclockwise", "Против часовой", self.auto_block_counterclockwise_from_selection_seed, "rotate-ccw", 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( "delete_points", "Удалить точки", self.delete_selected_points, "trash-2", QKeySequence("Ctrl+Del"), "Полностью удалить выбранные LED-точки из схемы", ) add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("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) grid_action.setChecked(self.settings.show_grid_by_default) add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки") background_edit_action = add_action( "background_edit", "Редактировать подложку", self.toggle_background_edit, "image", QKeySequence("Ctrl+Shift+I"), "Перемещать и растягивать подложку на схеме", ) background_edit_action.setCheckable(True) background_visibility_action = add_action( "background_visible", "Показать подложку", self.toggle_background_visibility, "eye", None, "Показать или скрыть подложку", ) background_visibility_action.setCheckable(True) add_action("background_clear", "Удалить подложку", self.clear_background, "trash-2", None, "Удалить изображение подложки") add_action("background_reset_scale", "Сбросить масштаб подложки", self.reset_background_scale, "archive-restore", None, "Вернуть масштаб подложки к 100%") arrows_action = add_action( "direction_arrows", "Стрелки направления", self.toggle_direction_arrows, "arrow-right", QKeySequence("Ctrl+Shift+A"), "Показать или скрыть стрелки направления на линиях", ) arrows_action.setCheckable(True) arrows_action.setChecked(self.settings.show_direction_arrows_by_default) led_ids_action = add_action( "led_ids", "Отображать id светодиодов", self.toggle_led_ids, "eye", None, "Показать или скрыть id LED в линии над выбранными точками", ) led_ids_action.setCheckable(True) led_ids_action.setChecked(self.settings.show_led_ids_by_default) 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["new"]) file_menu.addAction(self.actions["open"]) file_menu.addAction(self.actions["save"]) file_menu.addAction(self.actions["save_as"]) file_menu.addAction(self.actions["export_image"]) file_menu.addSeparator() file_menu.addAction(self.actions["generate_script"]) file_menu.addSeparator() file_menu.addAction(self.actions["settings"]) 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["delete_points"]) 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"]) view_menu.addAction(self.actions["led_ids"]) view_menu.addSeparator() background_menu = view_menu.addMenu(lucide_icon("image", self._icon_color()), "Подложка") background_menu.addAction(self.actions["background"]) background_menu.addAction(self.actions["background_visible"]) background_menu.addAction(self.actions["background_edit"]) background_menu.addAction(self.actions["background_reset_scale"]) background_menu.addAction(self.actions["background_clear"]) view_menu.addSeparator() view_menu.addAction(self.actions["toggle_sidebar"]) tools_menu = menu_bar.addMenu("Инструменты") tools_menu.addAction(self.actions["add_line"]) tools_menu.addAction(self.actions["add_led"]) tools_menu.addAction(self.actions["generate_script"]) tools_menu.addSeparator() tools_menu.addAction(self.actions["auto_blocks"]) tools_menu.addAction(self.actions["counterclockwise"]) 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.setContentsMargins(4, 4, 4, 4) top_panel = QWidget() top_layout = QVBoxLayout(top_panel) top_layout.setContentsMargins(0, 0, 0, 0) top_layout.addWidget(self.summary_label) top_layout.addWidget(self.active_label) structure_label = QLabel("Структура") structure_label.setMinimumWidth(0) structure_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) top_layout.addWidget(structure_label) top_layout.addWidget(self.tree, 1) buttons = QWidget() button_layout = QHBoxLayout(buttons) button_layout.setContentsMargins(0, 0, 0, 0) side_button_icons = { "add_line": "file-plus", "auto_blocks": "layers", } self.side_button_icon_names.update(side_button_icons) 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], self._icon_color())) 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 top_layout.addWidget(buttons) bottom_panel = QWidget() bottom_layout = QVBoxLayout(bottom_panel) bottom_layout.setContentsMargins(0, 0, 0, 0) bottom_layout.addWidget(self.selection_label) bottom_layout.addWidget(self.table, 1) bottom_layout.addWidget(self.status) content_splitter = QSplitter(Qt.Vertical) self.sidebar_content_splitter = content_splitter content_splitter.setChildrenCollapsible(False) content_splitter.addWidget(top_panel) content_splitter.addWidget(bottom_panel) content_splitter.setCollapsible(0, False) content_splitter.setCollapsible(1, False) content_splitter.setStretchFactor(0, 1) content_splitter.setStretchFactor(1, 1) content_splitter.splitterMoved.connect(self._on_sidebar_content_splitter_moved) content_splitter.setSizes(self.settings.sidebar_inner_sizes or [360, 420]) side_layout.addWidget(content_splitter, 1) 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._icon_color())) 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_content_splitter_moved(self, _pos: int, _index: int) -> None: if self.sidebar_content_splitter is None or self._sidebar_collapsed: return sizes = self.sidebar_content_splitter.sizes() if len(sizes) != 2 or min(sizes) <= 0: return self.settings.sidebar_inner_sizes = sizes self.settings.save() 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 toggle_led_ids(self) -> None: action = self.actions.get("led_ids") visible = True if action is None else action.isChecked() self.canvas.set_line_index_labels_visible(visible) def toggle_add_led_mode(self) -> None: action = self.actions.get("add_led") enabled = True if action is None else action.isChecked() self.canvas.set_add_led_enabled(enabled) if enabled: brush_action = self.actions.get("brush") if brush_action is not None: brush_action.setChecked(False) background_edit_action = self.actions.get("background_edit") if background_edit_action is not None: background_edit_action.setChecked(False) self.status.setText("Инструмент добавления LED: кликните по схеме, чтобы поставить свободный светодиод.") def open_background_dialog(self) -> None: dialog = BackgroundDialog(self.document.background, self) self._disable_shortcuts() try: res = dialog.exec() finally: self._enable_shortcuts() if res != BackgroundDialog.Accepted: return background = dialog.background() if not background.path: return image_path = Path(background.path) if not image_path.is_absolute() and self.document.path is not None: image_path = self.document.path.parent / image_path reader = QImageReader(str(image_path)) if not reader.canRead(): show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.") return if background.to_payload() == self.document.background.to_payload(): self.status.setText("Подложка не изменилась.") return self._pending_background_undo_state = None self._push_undo() self.canvas.set_background(background) self._dirty = True self.status.setText("Подложка обновлена.") self._refresh_ui_state() def clear_background(self) -> None: if not self.document.background.is_enabled(): return self._pending_background_undo_state = None self._push_undo() self.canvas.clear_background() self._dirty = True self.status.setText("Подложка удалена.") self._refresh_ui_state() def reset_background_scale(self) -> None: if not self.document.background.is_enabled(): return if self.document.background.scale_x_percent == 100.0 and self.document.background.scale_y_percent == 100.0: self.status.setText("Масштаб подложки уже 100%.") return self._pending_background_undo_state = None self._push_undo() self.canvas.reset_background_scale() self._dirty = True self.status.setText("Масштаб подложки сброшен.") self._refresh_ui_state() def toggle_background_visibility(self) -> None: if not self.document.background.is_enabled(): return action = self.actions.get("background_visible") visible = bool(action is not None and action.isChecked()) if self.document.background.visible == visible: return self._pending_background_undo_state = None self._push_undo() self.canvas.set_background_visible(visible) self._dirty = True self.status.setText("Подложка показана." if visible else "Подложка скрыта.") self._refresh_ui_state() def toggle_background_edit(self) -> None: action = self.actions.get("background_edit") enabled = bool(action is not None and action.isChecked()) if enabled and not self.document.background.is_enabled(): if action is not None: action.setChecked(False) self.status.setText("Сначала добавьте подложку.") return if enabled: brush_action = self.actions.get("brush") if brush_action is not None and brush_action.isChecked(): brush_action.setChecked(False) add_led_action = self.actions.get("add_led") if add_led_action is not None and add_led_action.isChecked(): add_led_action.setChecked(False) self.canvas.set_brush_enabled(False) self.canvas.set_background_edit_mode(enabled) def _on_background_edit_started(self) -> None: self._pending_background_undo_state = self._capture_state() def _on_background_changed(self) -> None: if self._pending_background_undo_state is not None: self._push_undo_state(self._pending_background_undo_state) self._pending_background_undo_state = None self._dirty = True self.status.setText("Подложка изменена.") self._refresh_ui_state() def _on_background_edit_finished(self, changed: bool) -> None: if not changed: self._pending_background_undo_state = None def open_settings(self) -> None: dialog = SettingsDialog(self.settings, self) self._disable_shortcuts() try: res = dialog.exec() finally: self._enable_shortcuts() if res != SettingsDialog.Accepted: return self.settings = dialog.settings() self.settings.save() apply_app_theme(self.settings.theme) self._refresh_theme_icons() self._apply_settings_to_actions() self._apply_canvas_settings() self.status.setText("Параметры сохранены.") self._refresh_ui_state() def _apply_settings_to_actions(self) -> None: self._apply_shortcuts_to_actions() grid_action = self.actions.get("grid") if grid_action is not None: grid_action.setChecked(self.settings.show_grid_by_default) arrows_action = self.actions.get("direction_arrows") if arrows_action is not None: arrows_action.setChecked(self.settings.show_direction_arrows_by_default) led_ids_action = self.actions.get("led_ids") if led_ids_action is not None: led_ids_action.setChecked(self.settings.show_led_ids_by_default) def _apply_shortcuts_to_actions(self) -> None: for key in self.shortcut_action_keys: action = self.actions.get(key) if action is not None: action.setShortcut(QKeySequence(self.settings.shortcut_for(key))) def _shortcut_sequence(self, key: str, fallback: QKeySequence) -> QKeySequence: shortcut = self.settings.shortcut_for(key) return QKeySequence(shortcut) if shortcut else QKeySequence() def _apply_canvas_settings(self) -> None: self.canvas.set_theme(effective_theme(self.settings.theme)) self.canvas.set_grid_visible(self.settings.show_grid_by_default) self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default) self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default) self.canvas.set_brush_radius(self.settings.brush_radius) self.canvas.set_zoom_focus_pull(self.settings.zoom_focus_pull) self.canvas.set_pan_mode(self.settings.pan_mode) self.canvas.set_instant_space_pan(self.settings.instant_space_pan) def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802 if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat(): if self._trigger_layout_independent_shortcut(event): return True return super().eventFilter(obj, event) def _trigger_layout_independent_shortcut(self, event) -> bool: # noqa: ANN001 active_window = QApplication.activeWindow() if active_window is not None and active_window is not self: return False sequence = self._physical_key_sequence(event) if not sequence: return False focus = QApplication.focusWidget() modifiers = event.modifiers() if isinstance(focus, QKeySequenceEdit): return False text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit)) if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)): return False for key in self.shortcut_action_keys: if self.settings.shortcut_for(key) != sequence: continue action = self.actions.get(key) if action is not None and action.isEnabled(): action.trigger() return True return False def _physical_key_sequence(self, event) -> str: # noqa: ANN001 key_name = physical_key_name(event) if not key_name: return "" modifiers = event.modifiers() parts: list[str] = [] if modifiers & Qt.ControlModifier: parts.append("Ctrl") if modifiers & Qt.AltModifier: parts.append("Alt") if modifiers & Qt.ShiftModifier: parts.append("Shift") if modifiers & Qt.MetaModifier: parts.append("Meta") parts.append(key_name) return "+".join(parts) def _icon_color(self) -> str: return "#111827" if effective_theme(self.settings.theme) == "light" else "#e5e7eb" def _refresh_theme_icons(self) -> None: icon_color = self._icon_color() app_icon = lucide_icon("git-branch", icon_color, 256) self.setWindowIcon(app_icon) application = QApplication.instance() if application is not None: application.setWindowIcon(app_icon) for key, icon_name in self.action_icon_names.items(): action = self.actions.get(key) if action is not None: action.setIcon(lucide_icon(icon_name, icon_color)) for key, icon_name in self.side_button_icon_names.items(): button = self.side_buttons.get(key) if button is not None: button.setIcon(lucide_icon(icon_name, icon_color)) if self.sidebar_tab is not None: self.sidebar_tab.setIcon(lucide_icon("chevron-right", icon_color)) for button in self.block_append_buttons.values(): button.setIcon(lucide_icon("plus", icon_color)) 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 new_file(self) -> None: if not self._confirm_discard_changes(): return path, _ = QFileDialog.getSaveFileName( self, "Создать новую схему", str(APP_DIR), "JSON files (*.json);;All files (*.*)", ) if not path: return p = Path(path) if not p.suffix: p = p.with_suffix(".json") try: p.write_text('{"circles": []}', encoding="utf-8") except Exception as exc: show_critical(self, "Ошибка создания файла", str(exc)) return loaded = MappingDocument() try: loaded.load(p) except Exception as exc: show_critical(self, "Ошибка загрузки нового файла", str(exc)) return self.document = loaded self._dirty = False self._undo_stack.clear() self._redo_stack.clear() self.active_line_id = 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() self.status.setText(f"Создана новая пустая схема: {p}") self._refresh_ui_state() def open_file(self) -> None: if not self._confirm_discard_changes(): return path, _ = QFileDialog.getOpenFileName( self, "Открыть карту LED", str(APP_DIR), "JSON files (*.json);;All files (*.*)", ) if not path: return loaded = MappingDocument() try: loaded.load(path) except Exception as exc: show_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 export_mapping_image(self) -> None: if not self.document.points: self.status.setText("Схема пуста. Экспорт отменён.") self._refresh_ui_state() return # Warn if there are unsaved changes if self._dirty: msg = QMessageBox(self) msg.setIcon(QMessageBox.Icon.Warning) msg.setWindowTitle("Несохранённые изменения") msg.setText( "Есть несохранённые изменения. Экспорт выполняется на основе последней сохранённой версии." "\nСачала сохраните файл, чтобы экспорт отражал актуальные данные." ) save_btn = msg.addButton("Сохранить и экспортировать", QMessageBox.ButtonRole.AcceptRole) continue_btn = msg.addButton("Экспортировать без сохранения", QMessageBox.ButtonRole.ActionRole) cancel_btn = msg.addButton("Отмена", QMessageBox.ButtonRole.RejectRole) msg.exec() clicked = msg.clickedButton() if clicked == cancel_btn: return if clicked == save_btn: if self.document.path is None: self.save_file_as() else: self._save_to(self.document.path) # Choose theme for export theme_msg = QMessageBox(self) theme_msg.setIcon(QMessageBox.Icon.Question) theme_msg.setWindowTitle("Выбор темы экспорта") theme_msg.setText("Выберите цветовую тему для экспортируемого изображения:") dark_btn = theme_msg.addButton("Тёмная тема", QMessageBox.ButtonRole.AcceptRole) light_btn = theme_msg.addButton("Светлая тема", QMessageBox.ButtonRole.ActionRole) cancel_theme_btn = theme_msg.addButton("Отмена", QMessageBox.ButtonRole.RejectRole) theme_msg.exec() clicked_theme = theme_msg.clickedButton() if clicked_theme == cancel_theme_btn: return theme = "dark" if clicked_theme == dark_btn else "light" default_name = "mapping.png" if self.document.path is not None: default_name = self.document.path.stem + ".png" self._disable_shortcuts() try: file_path, _ = QFileDialog.getSaveFileName( self, "Экспортировать маппинг в изображение", default_name, "PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)" ) finally: self._enable_shortcuts() if not file_path: return success = render_mapping_image(self.document, file_path, theme=theme) if success: self.status.setText(f"Схема успешно экспортирована в: {file_path}") else: show_warning(self, "Ошибка экспорта", "Не удалось экспортировать схему в изображение.") self.status.setText("Ошибка экспорта изображения.") self._refresh_ui_state() def generate_script(self, output_dir: str | Path | None = None) -> None: if isinstance(output_dir, bool): output_dir = None if not self.document.points: show_information(self, "Нет точек", "Откройте карту LED перед генерацией скрипта.") self._refresh_ui_state() return assigned_count = self._script_assigned_count() total_count = len(self.document.points) if assigned_count == 0: show_information( self, "Нет назначенных LED", "Ни один светодиод схемы не назначен. Назначьте LED в линии и блоки перед генерацией скрипта.", ) self.status.setText("Генерация отменена: нет назначенных LED.") self._refresh_ui_state() return if ( assigned_count < total_count and self.settings.confirm_partial_generation and not self._confirm_partial_script_generation(assigned_count, total_count) ): self.status.setText("Генерация отменена: пользователь продолжает редактирование.") self._refresh_ui_state() return if not self._save_document_before_generation(): self._refresh_ui_state() return if output_dir is None: if self.settings.default_output_dir: output_dir = self.settings.default_output_dir else: selected_dir = QFileDialog.getExistingDirectory( self, "Выберите папку для генерации скрипта", str(APP_DIR), ) if not selected_dir: return output_dir = selected_dir dialog = GenerationStrategyDialog(self) if dialog.exec() != QDialog.Accepted: self.status.setText("Генерация отменена.") self._refresh_ui_state() return generate_grid = dialog.grid_cb.isChecked() generate_blocks = dialog.blocks_cb.isChecked() try: result = generate_panel_mapping_from_document( self.document, output_dir, generate_grid=generate_grid, generate_blocks=generate_blocks, ) except Exception as exc: show_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()) show_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) localize_message_box(message) message.exec() return message.clickedButton() is generate_button def _save_document_before_generation(self) -> bool: if self.document.path is not None: return self._save_to(self.document.path) path, _ = QFileDialog.getSaveFileName( self, "Сохранить карту LED перед генерацией", str(ROOT_DIR / "led_mapping_editor.json"), "JSON files (*.json);;All files (*.*)", ) if not path: self.status.setText("Генерация отменена: карта LED не сохранена.") return False return self._save_to(Path(path)) def _save_to(self, path: Path) -> bool: if len(self.document.lines) > MAX_LINES: show_warning( self, "Карта не сохранена", f"Новая модель поддерживает максимум {MAX_LINES} линии. Сейчас в карте {len(self.document.lines)}.", ) self.status.setText(f"Сохранение отменено: линий {len(self.document.lines)}/{MAX_LINES}") self._refresh_ui_state() return False 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()) show_critical(self, "Ошибка сохранения", str(exc)) self._refresh_ui_state() return False 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() return True def add_line(self) -> None: if len(self.document.lines) >= MAX_LINES: show_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_manual_led(self, x: float, y: float) -> None: self._push_undo() point = self.document.add_manual_point(x, y) self._dirty = True self.canvas.set_document(self.document, reset_view=False) self.canvas.select_points([point.uid]) self.refresh_tree() self._on_selection_changed([point.uid]) self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.") self._refresh_ui_state() def edit_point_coordinates(self, point_id: int) -> None: if not 0 <= point_id < len(self.document.points): return point = self.document.get_point(point_id) dialog = QDialog(self) dialog.setWindowTitle(f"Редактировать LED ID {point.source_id}") layout = QVBoxLayout(dialog) form = QFormLayout() x_spin = self._coordinate_spin_box(point.x) y_spin = self._coordinate_spin_box(point.y) form.addRow("X", x_spin) form.addRow("Y", y_spin) layout.addLayout(form) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) localize_dialog_buttons(buttons) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) self._disable_shortcuts() try: res = dialog.exec() finally: self._enable_shortcuts() if res != QDialog.Accepted: return self.update_point_coordinates(point_id, x_spin.value(), y_spin.value(), source="dialog") def move_point_from_canvas(self, point_id: int, x: float, y: float) -> None: self.update_point_coordinates(point_id, x, y, source="drag") def update_point_coordinates(self, point_id: int, x: float, y: float, *, source: str = "edit") -> bool: if not 0 <= point_id < len(self.document.points): return False point = self.document.get_point(point_id) if abs(point.x - x) < 1e-9 and abs(point.y - y) < 1e-9: self.canvas.set_document(self.document, reset_view=False) self.canvas.select_points([point_id]) self._on_selection_changed([point_id]) return False self._push_undo() self.document.update_point_position(point.uid, x=x, y=y) self._dirty = True self.canvas.set_document(self.document, reset_view=False) self.canvas.select_points([point.uid]) self.refresh_tree() self._on_selection_changed([point.uid]) action_text = "перемещен" if source == "drag" else "обновлен" self.status.setText(f"LED ID {point.source_id}: координаты {action_text}.") self._refresh_ui_state([point.uid]) return True def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox: spin = QDoubleSpinBox() spin.setRange(-1_000_000_000.0, 1_000_000_000.0) spin.setDecimals(6) spin.setSingleStep(1.0) spin.setValue(float(value)) spin.setKeyboardTracking(False) return spin 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: show_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: self._auto_block_from_point(point_id, direction="clockwise") def auto_block_from_point_counterclockwise(self, point_id: int) -> None: self._auto_block_from_point(point_id, direction="counterclockwise") def auto_block_counterclockwise_from_selection_seed(self) -> None: point_ids = self.canvas.selected_point_ids() if len(point_ids) != 1: show_information(self, "Нет стартовой точки", "Выберите одну LED-точку для построения блока против часовой.") return self.auto_block_from_point_counterclockwise(point_ids[0]) def _auto_block_from_point(self, point_id: int, *, direction: str = "clockwise") -> 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: show_information( self, "Лимит линий", f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.", ) return is_counterclockwise = direction == "counterclockwise" preview = ( self.document.counterclockwise_block_from_seed_preview(point_id) if is_counterclockwise else self.document.clockwise_block_from_seed_preview(point_id) ) if not preview: show_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) direction_text = "против часовой" if is_counterclockwise else "по часовой" self._after_mapping_change( f"Автоматически добавлен {block.name} {direction_text}: {len(block.point_ids)} LED, " f"стартовая точка {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 = ask_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) if not any( self.document.points[point_id].line_id is not None or self.document.points[point_id].block_id is not None or self.document.points[point_id].line_index is not None or self.document.points[point_id].block_index is not None for point_id in point_ids if 0 <= point_id < len(self.document.points) ): self.status.setText("В выбранных LED нет фиксаций для удаления.") self._refresh_ui_state() return self._push_undo() removed = self.document.clear_point_assignments(point_ids) if removed == 0: 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 delete_selected_points(self) -> None: point_ids = self.canvas.selected_point_ids() if not point_ids: point_ids = self._selected_table_point_ids() if not point_ids: self.status.setText("Нет выбранных точек для удаления.") self._refresh_ui_state() return if not self._confirm_delete_points(len(point_ids)): self._refresh_ui_state() return active_line = self._get_line_or_none(self.active_line_id) self._push_undo() removed = self.document.remove_points(point_ids) if removed == 0: if self._undo_stack: self._undo_stack.pop() self.status.setText("Нет выбранных точек для удаления.") self._refresh_ui_state() return self._dirty = True self._restore_active_line(active_line) self.canvas.set_document(self.document, reset_view=False) self.canvas.set_active_line(self.active_line_id) self.canvas.select_points([]) self.refresh_tree() self._on_selection_changed([]) self.status.setText(f"Удалено точек: {removed}.") self._refresh_ui_state() 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 = ask_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 _select_tree_block(self, line_id: int, block_id: int) -> None: self.tree.blockSignals(True) try: self.tree.clearSelection() for line_index in range(self.tree.topLevelItemCount()): line_item = self.tree.topLevelItem(line_index) line_data = line_item.data(0, Qt.UserRole) if not line_data or line_data[0] != "line" or line_data[1] != line_id: continue line_item.setExpanded(True) for child_index in range(line_item.childCount()): block_item = line_item.child(child_index) block_data = block_item.data(0, Qt.UserRole) if block_data and block_data[0] == "block" and block_data[2] == block_id: block_item.setSelected(True) self.tree.setCurrentItem(block_item) return finally: self.tree.blockSignals(False) 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", self._icon_color())) 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 = None self._brush_stroke_started = False self._brush_append_to_selected = False self.canvas.set_brush_enabled(enabled) if enabled: add_led_action = self.actions.get("add_led") if add_led_action is not None: add_led_action.setChecked(False) self._sync_brush_preview_exclusions() if enabled: self.status.setText("Кисть включена: обычный мазок создает новый блок, Shift добавляет в выбранный блок.") else: self.status.setText("Кисть выключена.") self._refresh_ui_state() def start_brush_stroke(self, append_to_selected: bool) -> None: self._brush_append_to_selected = append_to_selected self._brush_stroke_started = False self._brush_pending_point_ids.clear() self._brush_pending_point_set.clear() self.brush_target = self._initial_brush_target() if append_to_selected else None self._sync_brush_preview_exclusions() if append_to_selected: if self.brush_target is None: self.status.setText("Кисть + Shift: выберите блок, к которому надо добавить LED.") else: 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"Кисть + Shift: добавление в {block_name}.") else: self.status.setText("Кисть: новый мазок создаст новый блок.") def add_brush_point_to_block(self, point_id: int) -> None: if self._brush_append_to_selected: target = self.brush_target or self._initial_brush_target() if target is None: self._sync_brush_preview_exclusions() return else: target = self.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() 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}.", selection_already_updated=True) self._select_tree_block(line_id, block_id) 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_append_to_selected = 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: selected_blocks = self._selected_tree_blocks() if selected_blocks: line_ids = {line_id for line_id, _block_id in selected_blocks} if len(line_ids) == 1: line_id = selected_blocks[0][0] point_ids: list[int] = [] for _line_id, block_id in selected_blocks: block = self._find_block_or_none(line_id, block_id) if block is not None: point_ids.extend(block.point_ids) self.active_line_id = line_id self.brush_target = selected_blocks[0] if len(selected_blocks) == 1 else None self.canvas.set_active_line(self.active_line_id) self.canvas.select_points(point_ids) self._sync_brush_preview_exclusions() return 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 and not item.isSelected(): self.tree.clearSelection() self.tree.setCurrentItem(item) item.setSelected(True) menu = QMenu(self) merge_action = menu.addAction("Объединить блоки\tCtrl+M") merge_action.setEnabled(self._can_merge_selected_tree_blocks()) rename_action = None data = None if item is not None: data = item.data(0, Qt.UserRole) if data: if data[0] == "block": rename_action = menu.addAction("Переименовать блок\tF2") elif data[0] == "line": rename_action = menu.addAction("Переименовать линию\tF2") label = "Удалить выбранную фиксацию\tCtrl+Del" if item is not None and data is not None: if data[0] == "line": label = "Удалить линию\tCtrl+Del" elif data[0] == "block": label = "Удалить блок\tCtrl+Del" delete_action = menu.addAction(label) delete_action.setEnabled(item is not None) clear_all_action = menu.addAction("Удалить все фиксации\tShift+Del") 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 == merge_action: self.merge_selected_blocks() elif rename_action and chosen == rename_action: if data[0] == "block": line_id, block_id = data[1], data[2] self.rename_block(line_id, block_id) elif data[0] == "line": line_id = data[1] self.rename_line(line_id) elif 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 _on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: data = item.data(0, Qt.UserRole) if data: if data[0] == "block": line_id, block_id = data[1], data[2] self.rename_block(line_id, block_id) elif data[0] == "line": line_id = data[1] self.rename_line(line_id) def rename_selected_tree_item(self) -> None: item = self.tree.currentItem() if item is not None: data = item.data(0, Qt.UserRole) if data: if data[0] == "block": line_id, block_id = data[1], data[2] self.rename_block(line_id, block_id) elif data[0] == "line": line_id = data[1] self.rename_line(line_id) def rename_block(self, line_id: int, block_id: int) -> None: block = self._find_block_or_none(line_id, block_id) if block is None: return self._disable_shortcuts() try: new_name, ok = QInputDialog.getText( self, "Переименовать блок", "Введите новое имя блока:", QLineEdit.EchoMode.Normal, block.name ) finally: self._enable_shortcuts() if ok and new_name.strip() and new_name.strip() != block.name: self._push_undo() block.name = new_name.strip() self._dirty = True self.status.setText(f"Блок переименован в '{block.name}'.") self.refresh_tree() self._refresh_ui_state() def rename_line(self, line_id: int) -> None: line = self._get_line_or_none(line_id) if line is None: return self._disable_shortcuts() try: new_name, ok = QInputDialog.getText( self, "Переименовать линию", "Введите новое имя линии:", QLineEdit.EchoMode.Normal, line.name ) finally: self._enable_shortcuts() if ok and new_name.strip() and new_name.strip() != line.name: self._push_undo() line.name = new_name.strip() self._dirty = True self.status.setText(f"Линия переименована в '{line.name}'.") self.refresh_tree() self._refresh_ui_state() def merge_selected_blocks(self) -> None: selected_blocks = self._selected_tree_blocks() if len(selected_blocks) < 2: self.status.setText("Выберите два или больше блока для объединения.") self._refresh_ui_state() return line_ids = {line_id for line_id, _block_id in selected_blocks} if len(line_ids) != 1: show_information(self, "Разные линии", "Объединять можно только блоки одной линии.") return line_id = selected_blocks[0][0] block_ids = [block_id for _line_id, block_id in selected_blocks] self._push_undo() merged = self.document.merge_blocks(line_id, block_ids) self._dirty = True self.active_line_id = line_id self.brush_target = (line_id, merged.id) self.canvas.select_points(merged.point_ids) self._after_mapping_change( f"Объединены блоки: {len(block_ids)} -> {merged.name}, LED: {len(merged.point_ids)}. " f"{self._unfolding_summary(line_id)}", selection_already_updated=True, ) 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()) delete_points_action = menu.addAction("Удалить выбранные точки") delete_points_action.setEnabled(bool(self._selected_table_point_ids())) 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 == delete_points_action: self.canvas.select_points(self._selected_table_point_ids()) self.delete_selected_points() 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 = [ "-" if point.line_index is None else point.line_index, self._format_coordinate(point.x), self._format_coordinate(point.y), point.row, point.col, "-" if point.line_id is None else point.line_id, "-" if point.block_id is None else point.block_id, point.source_id, ] 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)) if col in {X_COLUMN, Y_COLUMN}: item.setToolTip("Можно редактировать координату LED.") else: item.setFlags(item.flags() & ~Qt.ItemIsEditable) self.table.setItem(row, col, item) 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(point_ids) def _on_table_item_changed(self, item: QTableWidgetItem) -> None: if self._updating_table or item.column() not in {X_COLUMN, Y_COLUMN}: return point_id = item.data(Qt.UserRole) if not isinstance(point_id, int) or not (0 <= point_id < len(self.document.points)): return point = self.document.get_point(point_id) try: value = float(item.text().replace(",", ".")) except ValueError: self._restore_table_coordinate_item(item, point) self.status.setText("Координата должна быть числом.") return old_value = point.x if item.column() == X_COLUMN else point.y if abs(old_value - value) < 1e-9: item.setData(SORT_ROLE, self._table_sort_value(value)) return self._push_undo() self.document.update_point_position( point.uid, x=value if item.column() == X_COLUMN else None, y=value if item.column() == Y_COLUMN else None, ) self._dirty = True blocker = QSignalBlocker(self.table) item.setText(self._format_coordinate(value)) item.setData(SORT_ROLE, self._table_sort_value(value)) del blocker self.canvas.set_document(self.document, reset_view=False) self.canvas.select_points([point.uid]) self._on_selection_changed([point.uid]) self.status.setText(f"LED ID {point.source_id}: координаты обновлены.") def _restore_table_coordinate_item(self, item: QTableWidgetItem, point) -> None: # noqa: ANN001 value = point.x if item.column() == X_COLUMN else point.y blocker = QSignalBlocker(self.table) item.setText(self._format_coordinate(value)) item.setData(SORT_ROLE, self._table_sort_value(value)) del blocker def _select_rows_on_canvas(self) -> None: if self._updating_table: return ids = [] selection_model = self.table.selectionModel() rows = ( [index.row() for index in selection_model.selectedRows()] if selection_model is not None else sorted({item.row() for item in self.table.selectedItems()}) ) for row in rows: item = self.table.item(row, UID_COLUMN) if item is not None: 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, UID_COLUMN) 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 _selected_tree_blocks(self) -> list[tuple[int, int]]: blocks: list[tuple[int, int]] = [] for item in self.tree.selectedItems(): data = item.data(0, Qt.UserRole) if data and data[0] == "block" and self._block_exists(data[1], data[2]): blocks.append((data[1], data[2])) order: dict[tuple[int, int], int] = {} for line in self.document.lines: for block in line.blocks: order[(line.id, block.id)] = len(order) return sorted(set(blocks), key=lambda block_key: order.get(block_key, 10**9)) def _can_merge_selected_tree_blocks(self) -> bool: selected_blocks = self._selected_tree_blocks() return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1 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, point_ids: list[int] | None = None) -> None: if point_ids is None: point_ids = self.canvas.selected_point_ids() has_points = bool(self.document.points) has_lines = bool(self.document.lines) has_background = self.document.background.is_enabled() has_loaded_background = has_background and self.canvas.background_loaded() has_visible_background = has_loaded_background and self.document.background.visible 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, "export_image": 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_led": True, "add_block": has_points and has_active_line, "auto_blocks": has_points and has_active_line and has_selection, "clockwise": has_active_line, "counterclockwise": has_points and len(point_ids) == 1, "rebuild_route": has_active_line, "brush": has_points, "delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(), "delete_points": has_selection, "clear_all": has_lines, "clear_selection": has_selection, "fit_view": has_points or has_visible_background, "grid": True, "background": True, "background_visible": has_loaded_background, "background_edit": has_visible_background, "background_clear": has_background, "background_reset_scale": has_loaded_background, "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 background_edit_action = self.actions.get("background_edit") if background_edit_action is not None and not has_visible_background and background_edit_action.isChecked(): background_edit_action.setChecked(False) self.canvas.set_background_edit_mode(False) background_visible_action = self.actions.get("background_visible") if background_visible_action is not None: blocker = QSignalBlocker(background_visible_action) background_visible_action.setChecked(bool(has_background and self.document.background.visible)) del blocker 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) if block is None: button.setEnabled(False) continue block_point_set = set(block.point_ids) if len(selected_set) <= len(block.point_ids) else None button.setEnabled( has_selection and ( block_point_set is None or not selected_set.issubset(block_point_set) ) ) def _on_application_focus_changed(self, _old: QWidget | None, _new: QWidget | None) -> None: self._refresh_focus_sensitive_actions() def _refresh_focus_sensitive_actions(self) -> None: point_ids = self.canvas.selected_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 ) enabled = (bool(point_ids) and selected_has_assignment) or self._tree_has_focus_with_item() action = self.actions.get("delete") if action is not None: action.setEnabled(enabled) button = self.side_buttons.get("delete") if button is not None: button.setEnabled(enabled) 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 return self._tree_block_target() def _tree_block_target(self) -> tuple[int, int] | None: 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 _current_line_target(self) -> int | None: item = self.tree.currentItem() if item is None: return self.active_line_id data = item.data(0, Qt.UserRole) if data and data[0] == "line" and self._line_exists(data[1]): return data[1] if data and data[0] == "block" and self._line_exists(data[1]): return data[1] return self.active_line_id 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 _brush_insert_context(self) -> tuple[int | None, int | None]: tree_block = self._tree_block_target() if tree_block is not None: return tree_block selected_block = self._selected_block_target() if selected_block is not None: return selected_block return self._current_line_target(), None def _create_brush_target(self, point_id: int) -> tuple[int, int] | None: context_line_id, after_block_id = self._brush_insert_context() line = self._get_line_or_none(context_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, [], after_block_id=after_block_id) self.active_line_id = line.id self.brush_target = (line.id, block.id) self.refresh_tree() self._select_tree_block(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._push_undo_state(self._capture_state()) def _push_undo_state(self, state: tuple[MappingDocument, int | None, list[int], bool]) -> None: self._undo_stack.append(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 _disable_shortcuts(self) -> None: self._temp_shortcuts = {} for key, action in self.actions.items(): self._temp_shortcuts[key] = action.shortcut() action.setShortcut(QKeySequence()) def _enable_shortcuts(self) -> None: if hasattr(self, "_temp_shortcuts"): for key, shortcut in self._temp_shortcuts.items(): if key in self.actions: self.actions[key].setShortcut(shortcut) del self._temp_shortcuts 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 = ask_question( self, "Перезаписать блок", f"{len(assigned_points)} выбранных LED уже находятся в блоках. Перезаписать их новым блоком?", ) return answer == QMessageBox.StandardButton.Yes answer = ask_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 = ask_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 = ask_question( self, "Подтвердите удаление", f"Удалить {kind} '{name}' и снять фиксацию с {affected} LED?", ) return answer == QMessageBox.StandardButton.Yes def _confirm_delete_points(self, count: int) -> bool: answer = ask_question( self, "Удалить точки", f"Полностью удалить выбранные LED-точки из схемы: {count}?", ) return answer == QMessageBox.StandardButton.Yes def _confirm_discard_changes(self, message: str | None = None) -> bool: if not self._dirty: return True answer = ask_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 _table_sort_value(value) -> tuple[int, int | float | str]: # noqa: ANN001 if value == "-": return 1, 0 if isinstance(value, (int, float)): return 0, value text = str(value) if text.lstrip("-").isdigit(): return 0, int(text) try: return 0, float(text.replace(",", ".")) except ValueError: pass return 0, text @staticmethod def _format_coordinate(value: float) -> str: text = f"{value:.3f}".rstrip("0").rstrip(".") return text if text else "0" 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}"