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