from __future__ import annotations from copy import deepcopy from pathlib import Path import sys from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt, QTimer from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence from PySide6.QtWidgets import ( QButtonGroup, 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, QFrame, QWidgetAction, ) 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, LedPoint, 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, LedPoint, 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._copied_led_points: list[LedPoint] = [] self._paste_sequence = 0 self.settings = AppSettings.load() self.document.grid_step_mm = self.settings.grid_step_mm 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.menu_buttons_widget: QWidget | None = None self.right_panel: QFrame | None = None self.right_panel_buttons: dict[str, tuple[QToolButton, str]] = {} 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.pointsMoveFinished.connect(self.move_points_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.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() self._refresh_theme_icons() 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, "Пересобрать блоки активной линии с текущим шагом сетки") 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) add_action( "copy_leds", "Копировать светодиоды", self.copy_selected_leds, "copy", QKeySequence.Copy, "Скопировать выбранные LED в режиме редактирования", ) add_action( "paste_leds", "Вставить светодиоды", self.paste_copied_leds, "clipboard-paste", QKeySequence.Paste, "Вставить свободные копии LED со смещением на шаг сетки", ) 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", "Сетка", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку") 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")) edit_mode_action = add_action( "toggle_edit_mode", "Режим редактирования", self.toggle_edit_mode, "pencil", QKeySequence("Ctrl+E"), "Включить или выключить режим редактирования схемы", ) edit_mode_action.setCheckable(True) 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["copy_leds"]) edit_menu.addAction(self.actions["paste_leds"]) 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"]) # Delete old widget if recreating if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None: self.menu_buttons_widget.deleteLater() self.menu_buttons_widget = None # Add vertical separator and buttons next to the menu headers in the QMenuBar as a child overlay self.menu_buttons_widget = QWidget(menu_bar) layout = QHBoxLayout(self.menu_buttons_widget) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) # Vertical line separator separator = QFrame(self.menu_buttons_widget) separator.setFrameShape(QFrame.Shape.VLine) separator.setFrameShadow(QFrame.Shadow.Sunken) separator.setStyleSheet("color: #cbd5e1; margin: 2px 0;") layout.addWidget(separator) # Add Tools actions as buttons for key in ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush", "toggle_edit_mode"]: action = self.actions[key] btn = QToolButton(self.menu_buttons_widget) btn.setDefaultAction(action) btn.setIconSize(QSize(16, 16)) btn.setToolButtonStyle(Qt.ToolButtonIconOnly) btn.setAutoRaise(True) layout.addWidget(btn) self.menu_buttons_widget.show() QTimer.singleShot(0, self._reposition_menu_buttons) def resizeEvent(self, event) -> None: super().resizeEvent(event) self._reposition_menu_buttons() def _reposition_menu_buttons(self) -> None: if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None: return menu_bar = self.menuBar() actions = menu_bar.actions() if len(actions) < 4: return tools_action = actions[3] rect = menu_bar.actionGeometry(tools_action) if rect.isValid() and rect.right() > 0: h = menu_bar.height() w = self.menu_buttons_widget.sizeHint().width() self.menu_buttons_widget.setGeometry(rect.right() + 10, (h - 20) // 2, w, 20) 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) # Build right vertical edit panel self._build_right_edit_panel() # Central canvas container with right edit panel canvas_container = QWidget() canvas_layout = QHBoxLayout(canvas_container) canvas_layout.setContentsMargins(0, 0, 0, 0) canvas_layout.setSpacing(0) canvas_layout.addWidget(self.canvas, 1) canvas_layout.addWidget(self.right_panel) splitter = QSplitter(Qt.Horizontal) self.sidebar_splitter = splitter splitter.setChildrenCollapsible(False) splitter.addWidget(self.sidebar_stack) splitter.addWidget(canvas_container) 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() # Auto-enable edit mode if add_led is enabled edit_mode_action = self.actions.get("toggle_edit_mode") if enabled: if edit_mode_action is not None and not edit_mode_action.isChecked(): edit_mode_action.setChecked(True) self.toggle_edit_mode() if hasattr(self, "right_panel_buttons") and "draw" in self.right_panel_buttons: self.right_panel_buttons["draw"][0].setChecked(True) self.canvas.set_edit_tool("draw") brush_action = self.actions.get("brush") if brush_action is not None: brush_action.setChecked(False) self.canvas.set_brush_enabled(False) background_edit_action = self.actions.get("background_edit") if background_edit_action is not None: background_edit_action.setChecked(False) self.canvas.set_background_edit_mode(False) self.status.setText("Инструмент рисования LED: кликните по схеме или проведите мышью, чтобы поставить светодиоды.") else: self.canvas.set_add_led_enabled(False) if edit_mode_action is not None and edit_mode_action.isChecked(): if hasattr(self, "right_panel_buttons") and "select" in self.right_panel_buttons: self.right_panel_buttons["select"][0].setChecked(True) self.canvas.set_edit_tool("select") self.status.setText("Рисование светодиодов отключено.") def toggle_edit_mode(self) -> None: action = self.actions.get("toggle_edit_mode") enabled = bool(action is not None and action.isChecked()) self.canvas.set_edit_mode_enabled(enabled) if hasattr(self, "right_panel") and self.right_panel is not None: self.right_panel.setVisible(enabled) if enabled: if hasattr(self, "right_panel_buttons") and "select" in self.right_panel_buttons: self.right_panel_buttons["select"][0].setChecked(True) self.canvas.set_edit_tool("select") self.status.setText("Режим редактирования схемы включен. Доступны инструменты на панели справа.") else: self.canvas.set_edit_tool("select") 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_add_led_enabled(False) self.status.setText("Режим редактирования схемы выключен.") self._refresh_ui_state() def _on_edit_tool_changed(self, tool: str) -> None: self.canvas.set_edit_tool(tool) add_led_action = self.actions.get("add_led") if add_led_action is not None: add_led_action.setChecked(tool == "draw") if tool == "draw": self.status.setText("Инструмент рисования LED: кликните по схеме или проведите мышью, чтобы поставить светодиоды.") elif tool == "move": self.status.setText("Инструмент перемещения LED: перетаскивайте светодиоды мышью без использования клавиши Alt.") else: self.status.setText("Инструмент выделения LED: выделяйте светодиоды рамкой или кликом.") self.canvas._apply_default_cursor() def _build_right_edit_panel(self) -> None: self.right_panel = QFrame() self.right_panel.setFixedWidth(44) self.right_panel.setStyleSheet( "QFrame { background-color: #1e293b; border-left: 1px solid #475569; }" if effective_theme(self.settings.theme) == "dark" else "QFrame { background-color: #f8fafc; border-left: 1px solid #cbd5e1; }" ) layout = QVBoxLayout(self.right_panel) layout.setContentsMargins(4, 10, 4, 10) layout.setSpacing(8) layout.setAlignment(Qt.AlignTop) self.edit_tool_group = QButtonGroup(self) self.edit_tool_group.setExclusive(True) self.right_panel_buttons = {} icon_color = self._icon_color() tools = [ ("select", "mouse-pointer", "Инструмент выделения (Select)", lambda: self._on_edit_tool_changed("select")), ("move", "move", "Инструмент перемещения (Move)", lambda: self._on_edit_tool_changed("move")), ("draw", "plus-circle", "Инструмент рисования светодиодов (Draw)", lambda: self._on_edit_tool_changed("draw")), ] for key, icon, tooltip, handler in tools: btn = QToolButton(self.right_panel) btn.setCheckable(True) btn.setIcon(lucide_icon(icon, icon_color)) btn.setIconSize(QSize(20, 20)) btn.setAutoRaise(True) btn.setToolTip(tooltip) btn.clicked.connect(handler) if key == "select": btn.setChecked(True) self.edit_tool_group.addButton(btn) layout.addWidget(btn, 0, Qt.AlignHCenter) self.right_panel_buttons[key] = (btn, icon) sep = QFrame(self.right_panel) sep.setFrameShape(QFrame.Shape.HLine) sep.setFrameShadow(QFrame.Shadow.Sunken) sep.setStyleSheet("color: #475569; margin: 6px 0;") layout.addWidget(sep) actions = [ ("align_horiz", "align-horizontal", "Выровнять по горизонтали (вдоль средней линии Y)", self.align_selected_horizontally), ("align_vert", "align-vertical", "Выровнять по вертикали (вдоль средней линии X)", self.align_selected_vertically), ("grid_horiz", "grid-horizontal", "Расставить по горизонтали с заданным шагом сетки", self.arrange_selected_horizontally_grid), ("grid_vert", "grid-vertical", "Расставить по вертикали с заданным шагом сетки", self.arrange_selected_vertically_grid), ("align_grid", "grid", "Выровнять по сетке (по центру клеток)", self.align_selected_to_grid), ] for key, icon, tooltip, handler in actions: btn = QToolButton(self.right_panel) btn.setIcon(lucide_icon(icon, icon_color)) btn.setIconSize(QSize(20, 20)) btn.setAutoRaise(True) btn.setToolTip(tooltip) btn.clicked.connect(handler) layout.addWidget(btn, 0, Qt.AlignHCenter) self.right_panel_buttons[key] = (btn, icon) layout.addStretch(1) self.right_panel.setVisible(False) def update_multiple_points_coordinates(self, coordinates: dict[int, tuple[float, float]], description: str) -> None: if not coordinates: return self._push_undo() for point_id, (x, y) in coordinates.items(): point = self.document.get_point(point_id) self.document.update_point_position(point.uid, x=x, y=y) self._dirty = True self.canvas.set_document(self.document, reset_view=False) selected_ids = list(coordinates.keys()) self.canvas.select_points(selected_ids) self.refresh_tree() self._on_selection_changed(selected_ids) self.status.setText(description) def align_selected_horizontally(self) -> None: selected_ids = self.canvas.selected_point_ids() if len(selected_ids) < 2: self.status.setText("Выделите хотя бы 2 светодиода для выравнивания.") return points = [self.document.get_point(pid) for pid in selected_ids] avg_y = sum(p.y for p in points) / len(points) coords = {p.uid: (p.x, avg_y) for p in points} self.update_multiple_points_coordinates(coords, "Светодиоды выровнены по горизонтали.") def align_selected_vertically(self) -> None: selected_ids = self.canvas.selected_point_ids() if len(selected_ids) < 2: self.status.setText("Выделите хотя бы 2 светодиода для выравнивания.") return points = [self.document.get_point(pid) for pid in selected_ids] avg_x = sum(p.x for p in points) / len(points) coords = {p.uid: (avg_x, p.y) for p in points} self.update_multiple_points_coordinates(coords, "Светодиоды выровнены по вертикали.") def arrange_selected_horizontally_grid(self) -> None: selected_ids = self.canvas.selected_point_ids() if len(selected_ids) < 2: self.status.setText("Выделите хотя бы 2 светодиода для расстановки.") return points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.x) first_p = points[0] step = self.document.grid_step_in_document_units() coords = {} for i, p in enumerate(points): new_x = first_p.x + i * step coords[p.uid] = (new_x, first_p.y) self.update_multiple_points_coordinates( coords, f"Светодиоды расставлены по горизонтальной сетке с шагом {self.document.grid_step_mm:g} мм.", ) def arrange_selected_vertically_grid(self) -> None: selected_ids = self.canvas.selected_point_ids() if len(selected_ids) < 2: self.status.setText("Выделите хотя бы 2 светодиода для расстановки.") return points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.y, reverse=True) first_p = points[0] step = self.document.grid_step_in_document_units() coords = {} for i, p in enumerate(points): new_y = first_p.y - i * step coords[p.uid] = (first_p.x, new_y) self.update_multiple_points_coordinates( coords, f"Светодиоды расставлены по вертикальной сетке с шагом {self.document.grid_step_mm:g} мм.", ) def align_selected_to_grid(self) -> None: selected_ids = self.canvas.selected_point_ids() if not selected_ids: self.status.setText("Выделите светодиоды для выравнивания по сетке.") return points = [self.document.get_point(pid) for pid in selected_ids] # We need grid anchors to calculate the grid origin. # Find points that are NOT manual, or fallback to any points. anchors = [p for p in self.document.points if not p.manual] if not anchors: anchors = self.document.points if not anchors: self.status.setText("Нет опорных точек для расчета сетки.") return try: from .models import infer_grid_coordinate_axes except ImportError: from models import infer_grid_coordinate_axes step = self.document.grid_step_in_document_units() row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors) ref = anchors[0] coords = {} for p in points: row, col = self.document._grid_cell_for_coordinate(p.x, p.y, exclude_uid=p.uid) if row_axis == "x": new_x = ref.x + (row - ref.row) * step * row_sign new_y = ref.y + (col - ref.col) * step * col_sign else: new_y = ref.y + (row - ref.row) * step * row_sign new_x = ref.x + (col - ref.col) * step * col_sign coords[p.uid] = (new_x, new_y) self.update_multiple_points_coordinates(coords, "Светодиоды выровнены по сетке (по центру клеток).") 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.is_enabled(): return if not background.has_embedded_image(): 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_settings = deepcopy(self.settings) dialog_settings.grid_step_mm = self.document.grid_step_mm dialog = SettingsDialog(dialog_settings, self) self._disable_shortcuts() try: res = dialog.exec() finally: self._enable_shortcuts() if res != SettingsDialog.Accepted: return new_settings = dialog.settings() grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9 if grid_changed: previous_state = self._capture_state() self.document.grid_step_mm = new_settings.grid_step_mm self._push_undo_state(previous_state) self._dirty = True self.settings = new_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_grid_step_mm(self.document.grid_step_mm) self.canvas.set_point_diameter(self.settings.led_point_diameter_px) 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.ShortcutOverride: if self._text_input_owns_copy_paste(event): event.accept() return True if self._is_tree_bulk_delete_event(event): event.accept() return self.remove_selected_tree_item() 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 _is_tree_bulk_delete_event(self, event) -> bool: # noqa: ANN001 focus = QApplication.focusWidget() return ( self._physical_key_sequence(event) == "Ctrl+Del" and focus is not None and (focus is self.tree or self.tree.isAncestorOf(focus)) ) 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 if self._text_input_owns_copy_paste(event): 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 if self._is_tree_bulk_delete_event(event): return self.remove_selected_tree_item() 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 @staticmethod def _text_input_owns_copy_paste(event) -> bool: # noqa: ANN001 focus = QApplication.focusWidget() if not isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit)): return False return event.matches(QKeySequence.StandardKey.Copy) or event.matches(QKeySequence.StandardKey.Paste) 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)) # Refresh right panel icons and styling for key, (button, icon_name) in getattr(self, "right_panel_buttons", {}).items(): button.setIcon(lucide_icon(icon_name, icon_color)) is_dark = (effective_theme(self.settings.theme) == "dark") if hasattr(self, "right_panel") and self.right_panel is not None: if is_dark: self.right_panel.setStyleSheet(""" QFrame { background-color: #1e293b; border-left: 1px solid #475569; } QToolButton { border: 1px solid transparent; border-radius: 4px; padding: 3px; } QToolButton:hover { background-color: #334155; } QToolButton:checked { background-color: #1e3a8a; border-color: #3b82f6; } """) else: self.right_panel.setStyleSheet(""" QFrame { background-color: #f8fafc; border-left: 1px solid #cbd5e1; } QToolButton { border: 1px solid transparent; border-radius: 4px; padding: 3px; } QToolButton:hover { background-color: #e2e8f0; } QToolButton:checked { background-color: #dbeafe; border-color: #3b82f6; } """) if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None: if is_dark: self.menu_buttons_widget.setStyleSheet(""" QWidget { background: transparent; } QFrame { color: #cbd5e1; margin: 2px 0; } QToolButton { border: 1px solid transparent; border-radius: 4px; padding: 2px; } QToolButton:hover { background-color: #334155; } QToolButton:checked { background-color: #1e3a8a; border-color: #3b82f6; } """) else: self.menu_buttons_widget.setStyleSheet(""" QWidget { background: transparent; } QFrame { color: #475569; margin: 2px 0; } QToolButton { border: 1px solid transparent; border-radius: 4px; padding: 2px; } QToolButton:hover { background-color: #e2e8f0; } QToolButton:checked { background-color: #dbeafe; border-color: #3b82f6; } """) 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") loaded = MappingDocument() loaded.grid_step_mm = self.settings.grid_step_mm try: loaded.save(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._synchronize_canvas_point_positions() 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 _synchronize_canvas_point_positions(self) -> None: """Commit any active graphics-item positions before serializing the document.""" for point_id, x, y in self.canvas.document_point_positions(): if not 0 <= point_id < len(self.document.points): continue point = self.document.get_point(point_id) if abs(point.x - x) <= 1e-9 and abs(point.y - y) <= 1e-9: continue self.document.update_point_position(point_id, x=x, y=y) self._dirty = 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 copy_selected_leds(self) -> bool: if not self._edit_mode_is_enabled(): self.status.setText("Копирование LED доступно только в режиме редактирования.") self._refresh_ui_state() return False point_ids = self.canvas.selected_point_ids() if not point_ids: self.status.setText("Выберите светодиоды для копирования.") self._refresh_ui_state() return False self._copied_led_points = [deepcopy(self.document.get_point(point_id)) for point_id in point_ids] self._paste_sequence = 0 self.status.setText(f"Скопировано светодиодов: {len(self._copied_led_points)}.") self._refresh_ui_state(point_ids) return True def paste_copied_leds(self) -> bool: if not self._edit_mode_is_enabled(): self.status.setText("Вставка LED доступна только в режиме редактирования.") self._refresh_ui_state() return False if not self._copied_led_points: self.status.setText("Сначала скопируйте выбранные светодиоды.") self._refresh_ui_state() return False self._push_undo() self._paste_sequence += 1 offset = float(self.document.grid_step_in_document_units()) * self._paste_sequence pasted = self.document.add_point_copies( self._copied_led_points, offset_x=offset, offset_y=-offset, ) pasted_ids = [point.uid for point in pasted] self._dirty = True self.canvas.set_document(self.document, reset_view=False) self.canvas.select_points(pasted_ids) self.refresh_tree() self._on_selection_changed(pasted_ids) self.status.setText( f"Вставлено свободных светодиодов: {len(pasted)}. " f"Смещение: {self._paste_sequence} шаг(а) сетки вправо и вниз." ) self._refresh_ui_state(pasted_ids) return True def _edit_mode_is_enabled(self) -> bool: action = self.actions.get("toggle_edit_mode") return bool(action is not None and action.isChecked()) 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 move_points_from_canvas(self, moves: list[tuple[int, float, float]]) -> None: if not moves: return self._push_undo() for point_id, x, y in moves: point = self.document.get_point(point_id) self.document.update_point_position(point.uid, x=x, y=y) self._dirty = True self.canvas.set_document(self.document, reset_view=False) # Restore selection selected_ids = [m[0] for m in moves] self.canvas.select_points(selected_ids) self.refresh_tree() self._on_selection_changed(selected_ids) if len(moves) == 1: point = self.document.get_point(moves[0][0]) self.status.setText(f"LED ID {point.source_id}: координаты обновлены.") else: self.status.setText(f"Перемещено светодиодов: {len(moves)}.") 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(15) 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, "Пересобрать блоки", f"Пересобрать блоки активной линии по физическим соседям с шагом {self.document.grid_step_mm:g} мм?", ) 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: selected_blocks = self._selected_tree_blocks() if selected_blocks: blocks = [ self._find_block(line_id, block_id) for line_id, block_id in selected_blocks ] affected = len({point_id for block in blocks for point_id in block.point_ids}) if len(selected_blocks) == 1: if not self._confirm_delete("блок", blocks[0].name, affected): return False elif not self._confirm_delete_blocks(len(selected_blocks), affected): return False active_line = self._get_line_or_none(self.active_line_id) self._push_undo() removed = self.document.remove_blocks(selected_blocks) self._dirty = True self._restore_active_line(active_line) self.brush_target = None self.canvas.select_points([]) if len(selected_blocks) == 1: message = f"Удален блок и фиксация LED: {removed}" else: message = f"Удалено блоков: {len(selected_blocks)}, снята фиксация LED: {removed}" self._after_mapping_change(message, selection_already_updated=True) return True 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 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 = self._tree_delete_label(data) delete_action = menu.addAction(label) delete_action.setEnabled(bool(self._selected_tree_blocks()) or 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 _tree_delete_label(self, current_data) -> str: # noqa: ANN001 selected_block_count = len(self._selected_tree_blocks()) if selected_block_count > 1: return "Удалить выбранные блоки\tCtrl+Del" if selected_block_count == 1: return "Удалить блок\tCtrl+Del" if current_data and current_data[0] == "line": return "Удалить линию\tCtrl+Del" return "Удалить выбранную фиксацию\tCtrl+Del" 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) edit_mode_enabled = self._edit_mode_is_enabled() 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), "copy_leds": edit_mode_enabled and has_selection, "paste_leds": edit_mode_enabled and bool(self._copied_led_points), "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_blocks(self, count: int, affected: int) -> bool: answer = ask_question( self, "Подтвердите удаление", f"Удалить выбранные блоки: {count} и снять фиксацию с {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: return repr(float(value)) 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}"