LED-mapper/main_window.py
Danil Iarosh 65d7580977 Add tree context menu hotkeys and support renaming lines in GUI
- Добавлены локальные горячие клавиши для действий в дереве: F2 для переименования, Ctrl+Del для удаления, Ctrl+M для объединения блоков.
- Изменен дефолтный хоткей для удаления всех фиксаций (clear_all) на Shift+Del.
- Реализована функция rename_line для переименования линий по аналогии с блоками.
- В контекстное меню дерева добавлено отображение горячих клавиш справа от названий команд.
- Добавлены интеграционные тесты для верификации переименования линий и регистрации хоткеев.
2026-07-31 12:35:15 +03:00

2482 lines
112 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import sys
from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt
from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence
from PySide6.QtWidgets import (
QAbstractItemView,
QAbstractScrollArea,
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QInputDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
QKeySequenceEdit,
QLineEdit,
QPlainTextEdit,
QTextEdit,
)
try:
from .background_dialog import BackgroundDialog
from .canvas import LedCanvas
from .icons import lucide_icon
from .localization import (
ask_question,
localize_dialog_buttons,
localize_message_box,
show_critical,
show_information,
show_warning,
)
from .models import LedBlock, LedLine, MappingDocument
from .panel_mapping_generator import generate_panel_mapping_from_document
from .settings import DEFAULT_SHORTCUTS, AppSettings
from .settings_dialog import SettingsDialog
from .theme import apply_app_theme, effective_theme
from .unfolding import MAX_LINES
except ImportError:
from background_dialog import BackgroundDialog
from canvas import LedCanvas
from icons import lucide_icon
from localization import (
ask_question,
localize_dialog_buttons,
localize_message_box,
show_critical,
show_information,
show_warning,
)
from models import LedBlock, LedLine, MappingDocument
from panel_mapping_generator import generate_panel_mapping_from_document
from settings import DEFAULT_SHORTCUTS, AppSettings
from settings_dialog import SettingsDialog
from theme import apply_app_theme, effective_theme
from unfolding import MAX_LINES
ROOT_DIR = Path(__file__).resolve().parents[1]
APP_DIR = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else ROOT_DIR
SORT_ROLE = Qt.UserRole + 1
SIDEBAR_DEFAULT_WIDTH = 260
SIDEBAR_MIN_WIDTH = 180
SIDEBAR_COLLAPSED_WIDTH = 34
MAX_WIDGET_WIDTH = 16777215
ID_COLUMN = 0
X_COLUMN = 1
Y_COLUMN = 2
ROW_COLUMN = 3
COL_COLUMN = 4
LINE_COLUMN = 5
BLOCK_COLUMN = 6
UID_COLUMN = 7
_NATIVE_VIRTUAL_KEY_NAMES = {
0x08: "Backspace",
0x09: "Tab",
0x0D: "Return",
0x1B: "Esc",
0x2E: "Del",
0xBC: ",",
}
_NATIVE_VIRTUAL_KEY_NAMES.update({0x30 + index: str(index) for index in range(10)})
_NATIVE_VIRTUAL_KEY_NAMES.update({0x41 + index: chr(0x41 + index) for index in range(26)})
_NATIVE_VIRTUAL_KEY_NAMES.update({0x70 + index: f"F{index + 1}" for index in range(24)})
def physical_key_name(event) -> str: # noqa: ANN001
native_key = event.nativeVirtualKey()
if native_key in _NATIVE_VIRTUAL_KEY_NAMES:
return _NATIVE_VIRTUAL_KEY_NAMES[native_key]
key = event.key()
if Qt.Key_A <= key <= Qt.Key_Z:
return chr(ord("A") + key - Qt.Key_A)
if Qt.Key_0 <= key <= Qt.Key_9:
return chr(ord("0") + key - Qt.Key_0)
if Qt.Key_F1 <= key <= Qt.Key_F24:
return f"F{key - Qt.Key_F1 + 1}"
if key == Qt.Key_Delete:
return "Del"
if key == Qt.Key_Escape:
return "Esc"
if key == Qt.Key_Comma:
return ","
return QKeySequence(key).toString(QKeySequence.SequenceFormat.PortableText)
class SortableTableItem(QTableWidgetItem):
def __lt__(self, other: QTableWidgetItem) -> bool:
left = self.data(SORT_ROLE)
right = other.data(SORT_ROLE)
if left is not None and right is not None:
return left < right
return super().__lt__(other)
class SidebarTab(QWidget):
def __init__(self, expand_handler, parent: QWidget | None = None) -> None: # noqa: ANN001
super().__init__(parent)
self._expand_handler = expand_handler
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("Показать боковую панель")
def mousePressEvent(self, event) -> None: # noqa: ANN001
if event.button() == Qt.LeftButton:
self._expand_handler()
event.accept()
return
super().mousePressEvent(event)
class GenerationStrategyDialog(QDialog):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Выбор стратегии генерации")
self.setMinimumWidth(360)
layout = QVBoxLayout(self)
label = QLabel("Выберите элементы для включения в сгенерированный скрипт:")
layout.addWidget(label)
self.grid_cb = QCheckBox("Сетка светодиодов (виртуальная матрица)")
self.grid_cb.setChecked(True)
layout.addWidget(self.grid_cb)
self.blocks_cb = QCheckBox("Блоки светодиодов (подсветка по id и количеству)")
self.blocks_cb.setChecked(True)
layout.addWidget(self.blocks_cb)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
localize_dialog_buttons(buttons)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self.ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
self.grid_cb.toggled.connect(self.validate)
self.blocks_cb.toggled.connect(self.validate)
def validate(self) -> None:
if not self.grid_cb.isChecked() and not self.blocks_cb.isChecked():
self.ok_button.setEnabled(False)
else:
self.ok_button.setEnabled(True)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("LED Mapper Editor")
self.resize(1280, 820)
self.document = MappingDocument()
self.active_line_id: int | None = None
self._updating_table = False
self._dirty = False
self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
self._pending_background_undo_state: tuple[MappingDocument, int | None, list[int], bool] | None = None
self.settings = AppSettings.load()
apply_app_theme(self.settings.theme)
self.actions: dict[str, QAction] = {}
self.action_icon_names: dict[str, str] = {}
self.shortcut_action_keys: set[str] = set()
self.side_buttons: dict[str, QPushButton] = {}
self.side_button_icon_names: dict[str, str] = {}
self.block_append_buttons: dict[tuple[int, int], QPushButton] = {}
self.brush_target: tuple[int, int] | None = None
self._brush_stroke_started = False
self._brush_append_to_selected = False
self._brush_pending_point_ids: list[int] = []
self._brush_pending_point_set: set[int] = set()
self.sidebar_splitter: QSplitter | None = None
self.sidebar_content_splitter: QSplitter | None = None
self.sidebar_stack: QStackedWidget | None = None
self.side_panel: QWidget | None = None
self.sidebar_tab: QToolButton | None = None
self.sidebar_tab_container: SidebarTab | None = None
self._sidebar_collapsed = False
self._sidebar_last_width = SIDEBAR_DEFAULT_WIDTH
self.canvas = LedCanvas()
self._apply_canvas_settings()
self.canvas.selectionChanged.connect(self._on_selection_changed)
self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments)
self.canvas.deletePointsRequested.connect(self.delete_selected_points)
self.canvas.editPointRequested.connect(self.edit_point_coordinates)
self.canvas.pointMoveFinished.connect(self.move_point_from_canvas)
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
self.canvas.addLedRequested.connect(self.add_manual_led)
self.canvas.brushStrokeStarted.connect(self.start_brush_stroke)
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
self.canvas.backgroundEditStarted.connect(self._on_background_edit_started)
self.canvas.backgroundChanged.connect(self._on_background_changed)
self.canvas.backgroundEditFinished.connect(self._on_background_edit_finished)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Линии и блоки"])
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.tree.itemSelectionChanged.connect(self._on_tree_selection)
self.tree.customContextMenuRequested.connect(self._show_tree_menu)
self.tree.itemDoubleClicked.connect(self._on_tree_item_double_clicked)
self.tree_rename_action = QAction(self)
self.tree_rename_action.setShortcut(QKeySequence("F2"))
self.tree_rename_action.setShortcutContext(Qt.WidgetShortcut)
self.tree_rename_action.triggered.connect(self.rename_selected_tree_item)
self.tree.addAction(self.tree_rename_action)
self.actions["tree_rename"] = self.tree_rename_action
self.tree_delete_action = QAction(self)
self.tree_delete_action.setShortcut(QKeySequence("Ctrl+Del"))
self.tree_delete_action.setShortcutContext(Qt.WidgetShortcut)
self.tree_delete_action.triggered.connect(self.remove_selected_tree_item)
self.tree.addAction(self.tree_delete_action)
self.actions["tree_delete"] = self.tree_delete_action
self.tree_merge_action = QAction(self)
self.tree_merge_action.setShortcut(QKeySequence("Ctrl+M"))
self.tree_merge_action.setShortcutContext(Qt.WidgetShortcut)
self.tree_merge_action.triggered.connect(self.merge_selected_blocks)
self.tree.addAction(self.tree_merge_action)
self.actions["tree_merge"] = self.tree_merge_action
self.table = QTableWidget(0, 8)
self.table.setHorizontalHeaderLabels(
["ID", "X", "Y", "Ряд", "Кол.", "Линия", "Блок", "UID"]
)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
self.table.horizontalHeader().setMinimumSectionSize(24)
self.table.horizontalHeader().setStretchLastSection(False)
for column, width in enumerate((48, 72, 72, 54, 54, 58, 58, 54)):
self.table.setColumnWidth(column, width)
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.table.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
self.table.setMinimumWidth(0)
self.table.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.horizontalHeader().setSortIndicator(UID_COLUMN, Qt.AscendingOrder)
self.table.setSortingEnabled(True)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.itemSelectionChanged.connect(self._select_rows_on_canvas)
self.table.itemChanged.connect(self._on_table_item_changed)
self.table.customContextMenuRequested.connect(self._show_table_menu)
self.summary_label = QLabel("Файл: - | Точек: 0 | Линий: 0/4 | Блоков: 0 | Назначено: 0")
self.active_label = QLabel("Активная линия: -")
self.selection_label = QLabel("Выбрано: 0 LED")
self.status = QLabel("Откройте circles.json или ordered_circles.json")
for label in (self.summary_label, self.active_label, self.selection_label, self.status):
label.setMinimumWidth(0)
label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
label.setWordWrap(False)
self.tree.setMinimumWidth(0)
self.tree.setTextElideMode(Qt.ElideRight)
self.tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.tree.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
self.tree.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
self._build_actions()
self._build_layout()
application = QApplication.instance()
if application is not None:
application.focusChanged.connect(self._on_application_focus_changed)
application.installEventFilter(self)
self._refresh_ui_state()
def _build_actions(self) -> None:
def add_action(
key: str,
title: str,
handler,
icon_name: str | None = None,
shortcut: QKeySequence | None = None,
tooltip: str | None = None,
) -> QAction:
action = QAction(title, self)
if icon_name is not None:
self.action_icon_names[key] = icon_name
action.setIcon(lucide_icon(icon_name, self._icon_color()))
if shortcut is not None or key in DEFAULT_SHORTCUTS:
action.setShortcut(self._shortcut_sequence(key, shortcut or QKeySequence()))
action.setShortcutContext(Qt.ApplicationShortcut)
self.shortcut_action_keys.add(key)
if tooltip is not None:
action.setToolTip(tooltip)
action.setStatusTip(tooltip)
action.triggered.connect(handler)
self.actions[key] = action
return action
add_action("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("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,"))
add_action(
"generate_script",
"Сгенерировать скрипт",
self.generate_script,
"file-code",
QKeySequence("Ctrl+G"),
"Сгенерировать vm_map.h и вспомогательные файлы развертки",
)
add_action("add_line", "Новая линия", self.add_line, "file-plus", QKeySequence("Ctrl+L"))
add_led_action = add_action("add_led", "Новый светодиод", self.toggle_add_led_mode, "plus", QKeySequence("N"), "Добавить свободный LED на схему")
add_led_action.setCheckable(True)
add_action("add_block", "Блок из выбора", self.add_block_from_selection, "plus", None, "Создать блок в активной линии")
add_action("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "layers", QKeySequence("Ctrl+Shift+B"), "Разбить выделение на связанные блоки")
add_action("clockwise", "По часовой", self.auto_connect_active_line, "rotate-cw", None, "Пересчитать порядок LED по часовой стрелке")
add_action(
"counterclockwise",
"Против часовой",
self.auto_block_counterclockwise_from_selection_seed,
"rotate-ccw",
None,
"Автоматически добавить блок от выбранной LED против часовой стрелки",
)
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм")
brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме")
brush_action.setCheckable(True)
add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo)
add_action("redo", "Повторить", self.redo_last_change, "redo-2", QKeySequence.Redo)
self.delete_action = add_action(
"delete",
"Удалить фиксацию",
self.delete_current_assignment,
"trash-2",
QKeySequence.Delete,
"Удалить выбранные назначения LED, блок или линию",
)
add_action(
"delete_points",
"Удалить точки",
self.delete_selected_points,
"trash-2",
QKeySequence("Ctrl+Del"),
"Полностью удалить выбранные LED-точки из схемы",
)
add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("Shift+Del"))
add_action("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc"))
add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0"))
grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм")
grid_action.setCheckable(True)
grid_action.setChecked(self.settings.show_grid_by_default)
add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки")
background_edit_action = add_action(
"background_edit",
"Редактировать подложку",
self.toggle_background_edit,
"image",
QKeySequence("Ctrl+Shift+I"),
"Перемещать и растягивать подложку на схеме",
)
background_edit_action.setCheckable(True)
background_visibility_action = add_action(
"background_visible",
"Показать подложку",
self.toggle_background_visibility,
"eye",
None,
"Показать или скрыть подложку",
)
background_visibility_action.setCheckable(True)
add_action("background_clear", "Удалить подложку", self.clear_background, "trash-2", None, "Удалить изображение подложки")
add_action("background_reset_scale", "Сбросить масштаб подложки", self.reset_background_scale, "archive-restore", None, "Вернуть масштаб подложки к 100%")
arrows_action = add_action(
"direction_arrows",
"Стрелки направления",
self.toggle_direction_arrows,
"arrow-right",
QKeySequence("Ctrl+Shift+A"),
"Показать или скрыть стрелки направления на линиях",
)
arrows_action.setCheckable(True)
arrows_action.setChecked(self.settings.show_direction_arrows_by_default)
led_ids_action = add_action(
"led_ids",
"Отображать id светодиодов",
self.toggle_led_ids,
"eye",
None,
"Показать или скрыть id LED в линии над выбранными точками",
)
led_ids_action.setCheckable(True)
led_ids_action.setChecked(self.settings.show_led_ids_by_default)
add_action("toggle_sidebar", "Скрыть боковую панель", self.toggle_sidebar, "chevron-right", QKeySequence("F9"))
self._build_menu_bar()
self.addAction(self.delete_action)
def _build_menu_bar(self) -> None:
menu_bar = self.menuBar()
menu_bar.clear()
file_menu = menu_bar.addMenu("Файл")
file_menu.addAction(self.actions["open"])
file_menu.addAction(self.actions["save"])
file_menu.addAction(self.actions["save_as"])
file_menu.addSeparator()
file_menu.addAction(self.actions["generate_script"])
file_menu.addSeparator()
file_menu.addAction(self.actions["settings"])
edit_menu = menu_bar.addMenu("Правка")
edit_menu.addAction(self.actions["undo"])
edit_menu.addAction(self.actions["redo"])
edit_menu.addSeparator()
edit_menu.addAction(self.actions["delete"])
edit_menu.addAction(self.actions["delete_points"])
edit_menu.addAction(self.actions["clear_all"])
edit_menu.addAction(self.actions["clear_selection"])
view_menu = menu_bar.addMenu("Вид")
view_menu.addAction(self.actions["fit_view"])
view_menu.addAction(self.actions["grid"])
view_menu.addAction(self.actions["direction_arrows"])
view_menu.addAction(self.actions["led_ids"])
view_menu.addSeparator()
background_menu = view_menu.addMenu(lucide_icon("image", self._icon_color()), "Подложка")
background_menu.addAction(self.actions["background"])
background_menu.addAction(self.actions["background_visible"])
background_menu.addAction(self.actions["background_edit"])
background_menu.addAction(self.actions["background_reset_scale"])
background_menu.addAction(self.actions["background_clear"])
view_menu.addSeparator()
view_menu.addAction(self.actions["toggle_sidebar"])
tools_menu = menu_bar.addMenu("Инструменты")
tools_menu.addAction(self.actions["add_line"])
tools_menu.addAction(self.actions["add_led"])
tools_menu.addAction(self.actions["generate_script"])
tools_menu.addSeparator()
tools_menu.addAction(self.actions["auto_blocks"])
tools_menu.addAction(self.actions["counterclockwise"])
tools_menu.addSeparator()
tools_menu.addAction(self.actions["brush"])
def _build_layout(self) -> None:
side = QWidget()
self.side_panel = side
side.setMinimumWidth(0)
side.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
side_layout = QVBoxLayout(side)
side_layout.setContentsMargins(4, 4, 4, 4)
top_panel = QWidget()
top_layout = QVBoxLayout(top_panel)
top_layout.setContentsMargins(0, 0, 0, 0)
top_layout.addWidget(self.summary_label)
top_layout.addWidget(self.active_label)
structure_label = QLabel("Структура")
structure_label.setMinimumWidth(0)
structure_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
top_layout.addWidget(structure_label)
top_layout.addWidget(self.tree, 1)
buttons = QWidget()
button_layout = QHBoxLayout(buttons)
button_layout.setContentsMargins(0, 0, 0, 0)
side_button_icons = {
"add_line": "file-plus",
"auto_blocks": "layers",
}
self.side_button_icon_names.update(side_button_icons)
for key, title, handler, tooltip in (
("add_line", "Линия", self.add_line, f"Создать линию, максимум {MAX_LINES}"),
("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "Разбить выделение на связанные блоки"),
):
button = QPushButton(title)
button.setIcon(lucide_icon(side_button_icons[key], self._icon_color()))
button.setMinimumWidth(0)
button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
button.setToolTip(tooltip)
button.clicked.connect(handler)
button_layout.addWidget(button)
self.side_buttons[key] = button
top_layout.addWidget(buttons)
bottom_panel = QWidget()
bottom_layout = QVBoxLayout(bottom_panel)
bottom_layout.setContentsMargins(0, 0, 0, 0)
bottom_layout.addWidget(self.selection_label)
bottom_layout.addWidget(self.table, 1)
bottom_layout.addWidget(self.status)
content_splitter = QSplitter(Qt.Vertical)
self.sidebar_content_splitter = content_splitter
content_splitter.setChildrenCollapsible(False)
content_splitter.addWidget(top_panel)
content_splitter.addWidget(bottom_panel)
content_splitter.setCollapsible(0, False)
content_splitter.setCollapsible(1, False)
content_splitter.setStretchFactor(0, 1)
content_splitter.setStretchFactor(1, 1)
content_splitter.splitterMoved.connect(self._on_sidebar_content_splitter_moved)
content_splitter.setSizes(self.settings.sidebar_inner_sizes or [360, 420])
side_layout.addWidget(content_splitter, 1)
collapsed_tab = SidebarTab(self.expand_sidebar)
self.sidebar_tab_container = collapsed_tab
collapsed_tab.setFixedWidth(SIDEBAR_COLLAPSED_WIDTH)
collapsed_layout = QVBoxLayout(collapsed_tab)
collapsed_layout.setContentsMargins(0, 0, 0, 0)
collapsed_layout.addStretch(1)
self.sidebar_tab = QToolButton()
self.sidebar_tab.setIcon(lucide_icon("chevron-right", self._icon_color()))
self.sidebar_tab.setToolTip("Показать боковую панель")
self.sidebar_tab.setAutoRaise(True)
self.sidebar_tab.setCursor(Qt.PointingHandCursor)
self.sidebar_tab.clicked.connect(self.expand_sidebar)
collapsed_layout.addWidget(self.sidebar_tab, 0, Qt.AlignHCenter)
collapsed_layout.addStretch(1)
self.sidebar_stack = QStackedWidget()
self.sidebar_stack.setMinimumWidth(0)
self.sidebar_stack.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
self.sidebar_stack.addWidget(side)
self.sidebar_stack.addWidget(collapsed_tab)
splitter = QSplitter(Qt.Horizontal)
self.sidebar_splitter = splitter
splitter.setChildrenCollapsible(False)
splitter.addWidget(self.sidebar_stack)
splitter.addWidget(self.canvas)
splitter.setCollapsible(0, False)
splitter.setCollapsible(1, False)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.splitterMoved.connect(self._on_sidebar_splitter_moved)
splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 1000])
self.setCentralWidget(splitter)
def _on_sidebar_content_splitter_moved(self, _pos: int, _index: int) -> None:
if self.sidebar_content_splitter is None or self._sidebar_collapsed:
return
sizes = self.sidebar_content_splitter.sizes()
if len(sizes) != 2 or min(sizes) <= 0:
return
self.settings.sidebar_inner_sizes = sizes
self.settings.save()
def _on_sidebar_splitter_moved(self, _pos: int, _index: int) -> None:
if self.sidebar_splitter is None:
return
side_width = self.sidebar_splitter.sizes()[0]
if self._sidebar_collapsed:
if side_width >= SIDEBAR_MIN_WIDTH:
self._sidebar_last_width = side_width
self.expand_sidebar()
elif side_width < SIDEBAR_COLLAPSED_WIDTH:
self._set_sidebar_splitter_width(SIDEBAR_COLLAPSED_WIDTH)
return
if side_width < SIDEBAR_MIN_WIDTH:
self.collapse_sidebar()
elif side_width >= SIDEBAR_MIN_WIDTH:
self._sidebar_last_width = side_width
def _set_sidebar_splitter_width(self, side_width: int) -> None:
if self.sidebar_splitter is None:
return
sizes = self.sidebar_splitter.sizes()
total_width = max(sum(sizes), side_width + 1)
blocker = QSignalBlocker(self.sidebar_splitter)
self.sidebar_splitter.setSizes([side_width, max(1, total_width - side_width)])
del blocker
def toggle_sidebar(self) -> None:
if self._sidebar_collapsed:
self.expand_sidebar()
else:
self.collapse_sidebar()
def toggle_direction_arrows(self) -> None:
action = self.actions.get("direction_arrows")
visible = True if action is None else action.isChecked()
self.canvas.set_direction_arrows_visible(visible)
def toggle_led_ids(self) -> None:
action = self.actions.get("led_ids")
visible = True if action is None else action.isChecked()
self.canvas.set_line_index_labels_visible(visible)
def toggle_add_led_mode(self) -> None:
action = self.actions.get("add_led")
enabled = True if action is None else action.isChecked()
self.canvas.set_add_led_enabled(enabled)
if enabled:
brush_action = self.actions.get("brush")
if brush_action is not None:
brush_action.setChecked(False)
background_edit_action = self.actions.get("background_edit")
if background_edit_action is not None:
background_edit_action.setChecked(False)
self.status.setText("Инструмент добавления LED: кликните по схеме, чтобы поставить свободный светодиод.")
def open_background_dialog(self) -> None:
dialog = BackgroundDialog(self.document.background, self)
self._disable_shortcuts()
try:
res = dialog.exec()
finally:
self._enable_shortcuts()
if res != BackgroundDialog.Accepted:
return
background = dialog.background()
if not background.path:
return
image_path = Path(background.path)
if not image_path.is_absolute() and self.document.path is not None:
image_path = self.document.path.parent / image_path
reader = QImageReader(str(image_path))
if not reader.canRead():
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
return
if background.to_payload() == self.document.background.to_payload():
self.status.setText("Подложка не изменилась.")
return
self._pending_background_undo_state = None
self._push_undo()
self.canvas.set_background(background)
self._dirty = True
self.status.setText("Подложка обновлена.")
self._refresh_ui_state()
def clear_background(self) -> None:
if not self.document.background.is_enabled():
return
self._pending_background_undo_state = None
self._push_undo()
self.canvas.clear_background()
self._dirty = True
self.status.setText("Подложка удалена.")
self._refresh_ui_state()
def reset_background_scale(self) -> None:
if not self.document.background.is_enabled():
return
if self.document.background.scale_x_percent == 100.0 and self.document.background.scale_y_percent == 100.0:
self.status.setText("Масштаб подложки уже 100%.")
return
self._pending_background_undo_state = None
self._push_undo()
self.canvas.reset_background_scale()
self._dirty = True
self.status.setText("Масштаб подложки сброшен.")
self._refresh_ui_state()
def toggle_background_visibility(self) -> None:
if not self.document.background.is_enabled():
return
action = self.actions.get("background_visible")
visible = bool(action is not None and action.isChecked())
if self.document.background.visible == visible:
return
self._pending_background_undo_state = None
self._push_undo()
self.canvas.set_background_visible(visible)
self._dirty = True
self.status.setText("Подложка показана." if visible else "Подложка скрыта.")
self._refresh_ui_state()
def toggle_background_edit(self) -> None:
action = self.actions.get("background_edit")
enabled = bool(action is not None and action.isChecked())
if enabled and not self.document.background.is_enabled():
if action is not None:
action.setChecked(False)
self.status.setText("Сначала добавьте подложку.")
return
if enabled:
brush_action = self.actions.get("brush")
if brush_action is not None and brush_action.isChecked():
brush_action.setChecked(False)
add_led_action = self.actions.get("add_led")
if add_led_action is not None and add_led_action.isChecked():
add_led_action.setChecked(False)
self.canvas.set_brush_enabled(False)
self.canvas.set_background_edit_mode(enabled)
def _on_background_edit_started(self) -> None:
self._pending_background_undo_state = self._capture_state()
def _on_background_changed(self) -> None:
if self._pending_background_undo_state is not None:
self._push_undo_state(self._pending_background_undo_state)
self._pending_background_undo_state = None
self._dirty = True
self.status.setText("Подложка изменена.")
self._refresh_ui_state()
def _on_background_edit_finished(self, changed: bool) -> None:
if not changed:
self._pending_background_undo_state = None
def open_settings(self) -> None:
dialog = SettingsDialog(self.settings, self)
self._disable_shortcuts()
try:
res = dialog.exec()
finally:
self._enable_shortcuts()
if res != SettingsDialog.Accepted:
return
self.settings = dialog.settings()
self.settings.save()
apply_app_theme(self.settings.theme)
self._refresh_theme_icons()
self._apply_settings_to_actions()
self._apply_canvas_settings()
self.status.setText("Параметры сохранены.")
self._refresh_ui_state()
def _apply_settings_to_actions(self) -> None:
self._apply_shortcuts_to_actions()
grid_action = self.actions.get("grid")
if grid_action is not None:
grid_action.setChecked(self.settings.show_grid_by_default)
arrows_action = self.actions.get("direction_arrows")
if arrows_action is not None:
arrows_action.setChecked(self.settings.show_direction_arrows_by_default)
led_ids_action = self.actions.get("led_ids")
if led_ids_action is not None:
led_ids_action.setChecked(self.settings.show_led_ids_by_default)
def _apply_shortcuts_to_actions(self) -> None:
for key in self.shortcut_action_keys:
action = self.actions.get(key)
if action is not None:
action.setShortcut(QKeySequence(self.settings.shortcut_for(key)))
def _shortcut_sequence(self, key: str, fallback: QKeySequence) -> QKeySequence:
shortcut = self.settings.shortcut_for(key)
return QKeySequence(shortcut) if shortcut else QKeySequence()
def _apply_canvas_settings(self) -> None:
self.canvas.set_theme(effective_theme(self.settings.theme))
self.canvas.set_grid_visible(self.settings.show_grid_by_default)
self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default)
self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default)
self.canvas.set_brush_radius(self.settings.brush_radius)
self.canvas.set_zoom_focus_pull(self.settings.zoom_focus_pull)
self.canvas.set_pan_mode(self.settings.pan_mode)
self.canvas.set_instant_space_pan(self.settings.instant_space_pan)
def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802
if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat():
if self._trigger_layout_independent_shortcut(event):
return True
return super().eventFilter(obj, event)
def _trigger_layout_independent_shortcut(self, event) -> bool: # noqa: ANN001
active_window = QApplication.activeWindow()
if active_window is not None and active_window is not self:
return False
sequence = self._physical_key_sequence(event)
if not sequence:
return False
focus = QApplication.focusWidget()
modifiers = event.modifiers()
if isinstance(focus, QKeySequenceEdit):
return False
text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit))
if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)):
return False
for key in self.shortcut_action_keys:
if self.settings.shortcut_for(key) != sequence:
continue
action = self.actions.get(key)
if action is not None and action.isEnabled():
action.trigger()
return True
return False
def _physical_key_sequence(self, event) -> str: # noqa: ANN001
key_name = physical_key_name(event)
if not key_name:
return ""
modifiers = event.modifiers()
parts: list[str] = []
if modifiers & Qt.ControlModifier:
parts.append("Ctrl")
if modifiers & Qt.AltModifier:
parts.append("Alt")
if modifiers & Qt.ShiftModifier:
parts.append("Shift")
if modifiers & Qt.MetaModifier:
parts.append("Meta")
parts.append(key_name)
return "+".join(parts)
def _icon_color(self) -> str:
return "#111827" if effective_theme(self.settings.theme) == "light" else "#e5e7eb"
def _refresh_theme_icons(self) -> None:
icon_color = self._icon_color()
app_icon = lucide_icon("git-branch", icon_color, 256)
self.setWindowIcon(app_icon)
application = QApplication.instance()
if application is not None:
application.setWindowIcon(app_icon)
for key, icon_name in self.action_icon_names.items():
action = self.actions.get(key)
if action is not None:
action.setIcon(lucide_icon(icon_name, icon_color))
for key, icon_name in self.side_button_icon_names.items():
button = self.side_buttons.get(key)
if button is not None:
button.setIcon(lucide_icon(icon_name, icon_color))
if self.sidebar_tab is not None:
self.sidebar_tab.setIcon(lucide_icon("chevron-right", icon_color))
for button in self.block_append_buttons.values():
button.setIcon(lucide_icon("plus", icon_color))
def collapse_sidebar(self) -> None:
if self.sidebar_splitter is None or self.sidebar_stack is None or self._sidebar_collapsed:
return
sizes = self.sidebar_splitter.sizes()
if sizes and sizes[0] >= SIDEBAR_MIN_WIDTH:
self._sidebar_last_width = sizes[0]
total_width = max(sum(sizes), SIDEBAR_DEFAULT_WIDTH + 1)
blocker = QSignalBlocker(self.sidebar_splitter)
self.sidebar_stack.setCurrentIndex(1)
self.sidebar_stack.setMinimumWidth(SIDEBAR_COLLAPSED_WIDTH)
self.sidebar_stack.setMaximumWidth(MAX_WIDGET_WIDTH)
self.sidebar_splitter.setSizes([SIDEBAR_COLLAPSED_WIDTH, max(1, total_width - SIDEBAR_COLLAPSED_WIDTH)])
del blocker
self._sidebar_collapsed = True
action = self.actions.get("toggle_sidebar")
if action is not None:
action.setText("Показать боковую панель")
action.setToolTip("Показать боковую панель")
def expand_sidebar(self) -> None:
if self.sidebar_splitter is None or self.sidebar_stack is None:
return
sizes = self.sidebar_splitter.sizes()
total_width = max(sum(sizes), SIDEBAR_DEFAULT_WIDTH + 1)
side_width = max(SIDEBAR_MIN_WIDTH, self._sidebar_last_width)
if side_width >= total_width:
side_width = min(SIDEBAR_DEFAULT_WIDTH, total_width - 1)
blocker = QSignalBlocker(self.sidebar_splitter)
self.sidebar_stack.setMaximumWidth(MAX_WIDGET_WIDTH)
self.sidebar_stack.setMinimumWidth(0)
self.sidebar_stack.setCurrentIndex(0)
self.sidebar_splitter.setSizes([side_width, max(1, total_width - side_width)])
del blocker
self._sidebar_collapsed = False
action = self.actions.get("toggle_sidebar")
if action is not None:
action.setText("Скрыть боковую панель")
action.setToolTip("Скрыть боковую панель")
def 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 generate_script(self, output_dir: str | Path | None = None) -> None:
if isinstance(output_dir, bool):
output_dir = None
if not self.document.points:
show_information(self, "Нет точек", "Откройте карту LED перед генерацией скрипта.")
self._refresh_ui_state()
return
assigned_count = self._script_assigned_count()
total_count = len(self.document.points)
if assigned_count == 0:
show_information(
self,
"Нет назначенных LED",
"Ни один светодиод схемы не назначен. Назначьте LED в линии и блоки перед генерацией скрипта.",
)
self.status.setText("Генерация отменена: нет назначенных LED.")
self._refresh_ui_state()
return
if (
assigned_count < total_count
and self.settings.confirm_partial_generation
and not self._confirm_partial_script_generation(assigned_count, total_count)
):
self.status.setText("Генерация отменена: пользователь продолжает редактирование.")
self._refresh_ui_state()
return
if not self._save_document_before_generation():
self._refresh_ui_state()
return
if output_dir is None:
if self.settings.default_output_dir:
output_dir = self.settings.default_output_dir
else:
selected_dir = QFileDialog.getExistingDirectory(
self,
"Выберите папку для генерации скрипта",
str(APP_DIR),
)
if not selected_dir:
return
output_dir = selected_dir
dialog = GenerationStrategyDialog(self)
if dialog.exec() != QDialog.Accepted:
self.status.setText("Генерация отменена.")
self._refresh_ui_state()
return
generate_grid = dialog.grid_cb.isChecked()
generate_blocks = dialog.blocks_cb.isChecked()
try:
result = generate_panel_mapping_from_document(
self.document,
output_dir,
generate_grid=generate_grid,
generate_blocks=generate_blocks,
)
except Exception as exc:
show_critical(self, "Ошибка генерации", str(exc))
self.status.setText(f"Ошибка генерации скрипта: {exc}")
self._refresh_ui_state()
return
generated_list = "\n".join(str(path) for path in result.generated_files.values())
show_information(
self,
"Скрипт сгенерирован",
(
f"Точек: {result.led_count}\n"
f"Назначенных LED: {result.assigned_count}\n"
f"Свободных LED: {result.unassigned_count}\n"
f"Блоков: {result.block_count}\n"
f"Виртуальная матрица: {result.vm_rows} x {result.vm_cols}\n\n"
f"Файлы:\n{generated_list}"
),
)
self.status.setText(
f"Сгенерирован скрипт: {result.assigned_count}/{result.led_count} LED, "
f"матрица {result.vm_rows}x{result.vm_cols}: {result.output_dir}"
)
self._refresh_ui_state()
def _script_assigned_count(self) -> int:
return sum(1 for point in self.document.points if point.line_id is not None and point.line_index is not None)
def _confirm_partial_script_generation(self, assigned_count: int, total_count: int) -> bool:
message = QMessageBox(self)
message.setIcon(QMessageBox.Icon.Warning)
message.setWindowTitle("Назначены не все LED")
message.setText(
f"Назначено {assigned_count} из {total_count} светодиодов.\n\n"
"Сгенерировать скрипт сейчас? Неназначенные LED не попадут в рабочий маппинг."
)
generate_button = message.addButton("Сгенерировать скрипт", QMessageBox.ButtonRole.AcceptRole)
message.addButton("Продолжить редактирование", QMessageBox.ButtonRole.RejectRole)
message.setDefaultButton(generate_button)
localize_message_box(message)
message.exec()
return message.clickedButton() is generate_button
def _save_document_before_generation(self) -> bool:
if self.document.path is not None:
return self._save_to(self.document.path)
path, _ = QFileDialog.getSaveFileName(
self,
"Сохранить карту LED перед генерацией",
str(ROOT_DIR / "led_mapping_editor.json"),
"JSON files (*.json);;All files (*.*)",
)
if not path:
self.status.setText("Генерация отменена: карта LED не сохранена.")
return False
return self._save_to(Path(path))
def _save_to(self, path: Path) -> bool:
if len(self.document.lines) > MAX_LINES:
show_warning(
self,
"Карта не сохранена",
f"Новая модель поддерживает максимум {MAX_LINES} линии. Сейчас в карте {len(self.document.lines)}.",
)
self.status.setText(f"Сохранение отменено: линий {len(self.document.lines)}/{MAX_LINES}")
self._refresh_ui_state()
return False
active_line = self._get_line_or_none(self.active_line_id)
try:
self.document.save(path)
except Exception as exc:
self._restore_active_line(active_line)
self.canvas.refresh_styles()
self.refresh_tree()
self._on_selection_changed(self.canvas.selected_point_ids())
show_critical(self, "Ошибка сохранения", str(exc))
self._refresh_ui_state()
return False
self._dirty = False
self._undo_stack.clear()
self._redo_stack.clear()
self._restore_active_line(active_line)
self.canvas.refresh_styles()
self.refresh_tree()
self._on_selection_changed(self.canvas.selected_point_ids())
self.status.setText(f"Сохранено: {self._document_summary()}: {path}")
self._refresh_ui_state()
return True
def add_line(self) -> None:
if len(self.document.lines) >= MAX_LINES:
show_information(
self,
"Лимит линий",
f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.",
)
self.status.setText(f"Лимит модели: {MAX_LINES} линии. Сейчас: {len(self.document.lines)}")
self._refresh_ui_state()
return
self._push_undo()
line = self.document.add_line()
self._dirty = True
self.active_line_id = line.id
self.refresh_tree()
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
self._refresh_ui_state()
def add_manual_led(self, x: float, y: float) -> None:
self._push_undo()
point = self.document.add_manual_point(x, y)
self._dirty = True
self.canvas.set_document(self.document, reset_view=False)
self.canvas.select_points([point.uid])
self.refresh_tree()
self._on_selection_changed([point.uid])
self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.")
self._refresh_ui_state()
def edit_point_coordinates(self, point_id: int) -> None:
if not 0 <= point_id < len(self.document.points):
return
point = self.document.get_point(point_id)
dialog = QDialog(self)
dialog.setWindowTitle(f"Редактировать LED ID {point.source_id}")
layout = QVBoxLayout(dialog)
form = QFormLayout()
x_spin = self._coordinate_spin_box(point.x)
y_spin = self._coordinate_spin_box(point.y)
form.addRow("X", x_spin)
form.addRow("Y", y_spin)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
localize_dialog_buttons(buttons)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
self._disable_shortcuts()
try:
res = dialog.exec()
finally:
self._enable_shortcuts()
if res != QDialog.Accepted:
return
self.update_point_coordinates(point_id, x_spin.value(), y_spin.value(), source="dialog")
def move_point_from_canvas(self, point_id: int, x: float, y: float) -> None:
self.update_point_coordinates(point_id, x, y, source="drag")
def update_point_coordinates(self, point_id: int, x: float, y: float, *, source: str = "edit") -> bool:
if not 0 <= point_id < len(self.document.points):
return False
point = self.document.get_point(point_id)
if abs(point.x - x) < 1e-9 and abs(point.y - y) < 1e-9:
self.canvas.set_document(self.document, reset_view=False)
self.canvas.select_points([point_id])
self._on_selection_changed([point_id])
return False
self._push_undo()
self.document.update_point_position(point.uid, x=x, y=y)
self._dirty = True
self.canvas.set_document(self.document, reset_view=False)
self.canvas.select_points([point.uid])
self.refresh_tree()
self._on_selection_changed([point.uid])
action_text = "перемещен" if source == "drag" else "обновлен"
self.status.setText(f"LED ID {point.source_id}: координаты {action_text}.")
self._refresh_ui_state([point.uid])
return True
def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(-1_000_000_000.0, 1_000_000_000.0)
spin.setDecimals(6)
spin.setSingleStep(1.0)
spin.setValue(float(value))
spin.setKeyboardTracking(False)
return spin
def add_block_from_selection(self) -> None:
line_id = self._require_active_line()
if line_id is None:
return
point_ids = self.canvas.selected_point_ids()
if point_ids and not self._confirm_reassignment(point_ids):
return
self._push_undo()
block = self.document.add_block(line_id, point_ids)
self._dirty = True
self.canvas.select_points(block.point_ids)
self._after_mapping_change(
f"Создан {block.name}: {len(block.point_ids)} LED. {self._unfolding_summary(line_id)}",
selection_already_updated=True,
)
def auto_blocks_from_selection(self) -> None:
line_id = self._require_active_line()
if line_id is None:
return
point_ids = self.canvas.selected_point_ids()
if not point_ids:
show_information(self, "Нет выбора", "Выберите LED-точки для разбиения на блоки.")
return
preview = self.document.auto_block_preview(point_ids)
if not self._confirm_auto_blocks(point_ids, preview):
return
self._push_undo()
before = len(self.document.get_line(line_id).blocks)
self.document.auto_blocks_from_selection(line_id, point_ids, preview)
self._dirty = True
added = len(self.document.get_line(line_id).blocks) - before
self._after_mapping_change(
f"Развертка пересчитана по часовой стрелке: добавлено блоков {added}. {self._unfolding_summary(line_id)}"
)
def auto_block_from_point(self, point_id: int) -> None:
self._auto_block_from_point(point_id, direction="clockwise")
def auto_block_from_point_counterclockwise(self, point_id: int) -> None:
self._auto_block_from_point(point_id, direction="counterclockwise")
def auto_block_counterclockwise_from_selection_seed(self) -> None:
point_ids = self.canvas.selected_point_ids()
if len(point_ids) != 1:
show_information(self, "Нет стартовой точки", "Выберите одну LED-точку для построения блока против часовой.")
return
self.auto_block_from_point_counterclockwise(point_ids[0])
def _auto_block_from_point(self, point_id: int, *, direction: str = "clockwise") -> None:
line_id = self.active_line_id
needs_new_line = line_id is None and not self.document.lines
if line_id is None and self.document.lines:
line_id = self.document.lines[0].id
self.active_line_id = line_id
if line_id is None and len(self.document.lines) >= MAX_LINES:
show_information(
self,
"Лимит линий",
f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.",
)
return
is_counterclockwise = direction == "counterclockwise"
preview = (
self.document.counterclockwise_block_from_seed_preview(point_id)
if is_counterclockwise
else self.document.clockwise_block_from_seed_preview(point_id)
)
if not preview:
show_information(self, "Нет блока", "Не удалось найти связанный блок от выбранной LED.")
return
if not self._confirm_reassignment(preview):
return
self._push_undo()
if needs_new_line:
line = self.document.add_line()
line_id = line.id
self.active_line_id = line_id
block = self.document.add_clockwise_block_from_group(line_id, point_id, preview)
self._dirty = True
self.canvas.select_points(block.point_ids)
direction_text = "против часовой" if is_counterclockwise else "по часовой"
self._after_mapping_change(
f"Автоматически добавлен {block.name} {direction_text}: {len(block.point_ids)} LED, "
f"стартовая точка {point_id}. "
f"{self._unfolding_summary(line_id)}",
selection_already_updated=True,
)
def auto_connect_active_line(self) -> None:
line_id = self._require_active_line()
if line_id is None:
return
self._push_undo()
self.document.auto_connect_line(line_id)
self._dirty = True
self._after_mapping_change(f"Развертка линии пересчитана по часовой стрелке. {self._unfolding_summary(line_id)}")
def rebuild_active_line_blocks(self) -> None:
line_id = self._require_active_line()
if line_id is None:
return
line = self.document.get_line(line_id)
if not line.blocks:
self.status.setText("В активной линии нет блоков для пересборки.")
return
answer = ask_question(
self,
"Пересобрать блоки",
"Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?",
)
if answer != QMessageBox.StandardButton.Yes:
return
self._push_undo()
block_count = self.document.rebuild_line_blocks_by_route(line_id)
self._dirty = True
self._after_mapping_change(f"Активная линия пересобрана: блоков {block_count}. {self._unfolding_summary(line_id)}")
def toggle_grid(self) -> None:
action = self.actions.get("grid")
self.canvas.set_grid_visible(action.isChecked() if action is not None else False)
def delete_current_assignment(self) -> None:
focus = self.focusWidget()
if focus is not None and (focus is self.tree or self.tree.isAncestorOf(focus)):
if self.remove_selected_tree_item():
return
self.clear_selected_point_assignments()
def clear_selected_point_assignments(self) -> None:
point_ids = self.canvas.selected_point_ids()
if not point_ids:
return
active_line = self._get_line_or_none(self.active_line_id)
if not any(
self.document.points[point_id].line_id is not None
or self.document.points[point_id].block_id is not None
or self.document.points[point_id].line_index is not None
or self.document.points[point_id].block_index is not None
for point_id in point_ids
if 0 <= point_id < len(self.document.points)
):
self.status.setText("В выбранных LED нет фиксаций для удаления.")
self._refresh_ui_state()
return
self._push_undo()
removed = self.document.clear_point_assignments(point_ids)
if removed == 0:
self.status.setText("В выбранных LED нет фиксаций для удаления.")
self._refresh_ui_state()
return
self._dirty = True
self._restore_active_line(active_line)
self.canvas.select_points(point_ids)
self._after_mapping_change(f"Удалена фиксация LED: {removed}", selection_already_updated=True)
def delete_selected_points(self) -> None:
point_ids = self.canvas.selected_point_ids()
if not point_ids:
point_ids = self._selected_table_point_ids()
if not point_ids:
self.status.setText("Нет выбранных точек для удаления.")
self._refresh_ui_state()
return
if not self._confirm_delete_points(len(point_ids)):
self._refresh_ui_state()
return
active_line = self._get_line_or_none(self.active_line_id)
self._push_undo()
removed = self.document.remove_points(point_ids)
if removed == 0:
if self._undo_stack:
self._undo_stack.pop()
self.status.setText("Нет выбранных точек для удаления.")
self._refresh_ui_state()
return
self._dirty = True
self._restore_active_line(active_line)
self.canvas.set_document(self.document, reset_view=False)
self.canvas.set_active_line(self.active_line_id)
self.canvas.select_points([])
self.refresh_tree()
self._on_selection_changed([])
self.status.setText(f"Удалено точек: {removed}.")
self._refresh_ui_state()
def remove_selected_tree_item(self) -> bool:
item = self.tree.currentItem()
if item is None:
return False
data = item.data(0, Qt.UserRole)
if not data:
return False
if data[0] == "line":
if not self._line_exists(data[1]):
self.refresh_tree()
return False
line = self.document.get_line(data[1])
affected = self._line_count(line)
if not self._confirm_delete("линию", line.name, affected):
return False
self._push_undo()
removed = self.document.remove_line(data[1])
self._dirty = True
self.active_line_id = self.document.lines[0].id if self.document.lines else None
self.canvas.select_points([])
self._after_mapping_change(f"Удалена линия и фиксация LED: {removed}", selection_already_updated=True)
return True
if data[0] == "block":
if not self._block_exists(data[1], data[2]):
self.refresh_tree()
return False
active_line = self._get_line_or_none(data[1])
block = self._find_block_or_none(data[1], data[2])
if block is None:
return False
if not self._confirm_delete("блок", block.name, len(block.point_ids)):
return False
self._push_undo()
removed = self.document.remove_block(data[1], data[2])
self._dirty = True
self._restore_active_line(active_line)
self.canvas.select_points([])
self._after_mapping_change(f"Удален блок и фиксация LED: {removed}", selection_already_updated=True)
return True
return False
def clear_all_assignments(self) -> None:
if not self.document.lines:
return
answer = ask_question(
self,
"Удалить все фиксации",
"Удалить все линии, блоки и назначенные индексы LED?",
)
if answer != QMessageBox.StandardButton.Yes:
return
self._push_undo()
removed = self.document.clear_all_assignments()
self._dirty = True
self.active_line_id = None
self.canvas.select_points([])
self._after_mapping_change(f"Удалены все фиксации LED: {removed}", selection_already_updated=True)
def refresh_tree(self) -> None:
self.tree.setUpdatesEnabled(False)
self.tree.blockSignals(True)
try:
self.tree.clear()
self.block_append_buttons.clear()
for line in self.document.lines:
title = f"{line.name} ({self._line_count(line)} LED, блоков: {len(line.blocks)})"
line_item = QTreeWidgetItem([title])
line_item.setData(0, Qt.UserRole, ("line", line.id))
line_item.setForeground(0, QColor(line.color))
self.tree.addTopLevelItem(line_item)
line_offset = 0
for block in line.blocks:
index_to = line_offset + len(block.point_ids) - 1
index_range = "-" if not block.point_ids else f"{line_offset}-{index_to}"
block_item = QTreeWidgetItem([""])
block_item.setData(0, Qt.UserRole, ("block", line.id, block.id))
block_item.setSizeHint(0, QSize(0, 24))
line_item.addChild(block_item)
self._attach_block_widget(block_item, line.id, block.id, block.name, len(block.point_ids), index_range)
line_offset += len(block.point_ids)
line_item.setExpanded(True)
if line.id == self.active_line_id:
self.tree.setCurrentItem(line_item)
finally:
self.tree.blockSignals(False)
self.tree.setUpdatesEnabled(True)
self._refresh_block_append_buttons()
def _select_tree_block(self, line_id: int, block_id: int) -> None:
self.tree.blockSignals(True)
try:
self.tree.clearSelection()
for line_index in range(self.tree.topLevelItemCount()):
line_item = self.tree.topLevelItem(line_index)
line_data = line_item.data(0, Qt.UserRole)
if not line_data or line_data[0] != "line" or line_data[1] != line_id:
continue
line_item.setExpanded(True)
for child_index in range(line_item.childCount()):
block_item = line_item.child(child_index)
block_data = block_item.data(0, Qt.UserRole)
if block_data and block_data[0] == "block" and block_data[2] == block_id:
block_item.setSelected(True)
self.tree.setCurrentItem(block_item)
return
finally:
self.tree.blockSignals(False)
def _attach_block_widget(
self,
item: QTreeWidgetItem,
line_id: int,
block_id: int,
block_name: str,
led_count: int,
index_range: str,
) -> None:
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(16, 0, 2, 0)
layout.setSpacing(4)
label = QLabel(f"{block_name} ({led_count} LED, idx {index_range})")
label.setAlignment(Qt.AlignVCenter)
label.setMinimumWidth(0)
label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
button = QPushButton()
button.setIcon(lucide_icon("plus", self._icon_color()))
button.setFixedSize(22, 20)
button.setToolTip("Добавить выбранные LED в конец этого блока")
button.clicked.connect(lambda _checked=False, lid=line_id, bid=block_id: self.append_selection_to_block(lid, bid))
layout.addWidget(label, 1)
layout.addWidget(button, 0)
self.block_append_buttons[(line_id, block_id)] = button
self.tree.setItemWidget(item, 0, widget)
def append_selection_to_block(self, line_id: int, block_id: int) -> None:
block = self._find_block_or_none(line_id, block_id)
if block is None:
self.refresh_tree()
return
point_ids = self.canvas.selected_point_ids()
append_ids = [point_id for point_id in point_ids if point_id not in set(block.point_ids)]
if not append_ids:
self.status.setText("Выберите LED, которых еще нет в этом блоке.")
self._refresh_ui_state()
return
previous_active_line_id = self.active_line_id
self.active_line_id = line_id
if not self._confirm_reassignment(append_ids):
self.active_line_id = previous_active_line_id
self._refresh_ui_state()
return
self._push_undo()
added = self.document.append_points_to_block(line_id, block_id, append_ids)
if added == 0:
self._undo_stack.pop()
self.active_line_id = previous_active_line_id
self.status.setText("В выбранных LED нет новых точек для добавления в блок.")
self._refresh_ui_state()
return
self._dirty = True
self.active_line_id = line_id
self.canvas.select_points(append_ids)
updated_block = self._find_block_or_none(line_id, block_id)
block_name = block.name if updated_block is None else updated_block.name
self._after_mapping_change(
f"В {block_name} добавлено LED: {added}. {self._unfolding_summary(line_id)}",
selection_already_updated=True,
)
def toggle_brush_mode(self) -> None:
action = self.actions.get("brush")
enabled = action.isChecked() if action is not None else False
self.brush_target = None
self._brush_stroke_started = False
self._brush_append_to_selected = False
self.canvas.set_brush_enabled(enabled)
if enabled:
add_led_action = self.actions.get("add_led")
if add_led_action is not None:
add_led_action.setChecked(False)
self._sync_brush_preview_exclusions()
if enabled:
self.status.setText("Кисть включена: обычный мазок создает новый блок, Shift добавляет в выбранный блок.")
else:
self.status.setText("Кисть выключена.")
self._refresh_ui_state()
def start_brush_stroke(self, append_to_selected: bool) -> None:
self._brush_append_to_selected = append_to_selected
self._brush_stroke_started = False
self._brush_pending_point_ids.clear()
self._brush_pending_point_set.clear()
self.brush_target = self._initial_brush_target() if append_to_selected else None
self._sync_brush_preview_exclusions()
if append_to_selected:
if self.brush_target is None:
self.status.setText("Кисть + Shift: выберите блок, к которому надо добавить LED.")
else:
line_id, block_id = self.brush_target
block = self._find_block_or_none(line_id, block_id)
block_name = "блок" if block is None else block.name
self.status.setText(f"Кисть + Shift: добавление в {block_name}.")
else:
self.status.setText("Кисть: новый мазок создаст новый блок.")
def add_brush_point_to_block(self, point_id: int) -> None:
if self._brush_append_to_selected:
target = self.brush_target or self._initial_brush_target()
if target is None:
self._sync_brush_preview_exclusions()
return
else:
target = self.brush_target
if target is None:
if not self._brush_stroke_started:
self._push_undo()
self._brush_stroke_started = True
target = self._create_brush_target(point_id)
if target is None:
if self._undo_stack:
self._undo_stack.pop()
self._brush_stroke_started = False
self._refresh_ui_state()
return
line_id, block_id = target
block = self._find_block_or_none(line_id, block_id)
if block is None:
self._disable_brush("Целевой блок больше не существует.")
self.refresh_tree()
return
if point_id in block.point_ids or point_id in self._brush_pending_point_set:
self.active_line_id = line_id
self.brush_target = (line_id, block_id)
self._sync_brush_preview_exclusions()
return
if not self._brush_stroke_started:
self._push_undo()
self._brush_stroke_started = True
self.active_line_id = line_id
self.brush_target = (line_id, block_id)
self._brush_pending_point_ids.append(point_id)
self._brush_pending_point_set.add(point_id)
self._sync_brush_preview_exclusions()
self.status.setText(f"Кисть: добавлено в мазок LED {len(self._brush_pending_point_ids)}.")
def finish_brush_stroke(self) -> None:
pending_ids = self._brush_pending_point_ids[:]
self._brush_pending_point_ids.clear()
self._brush_pending_point_set.clear()
self._brush_stroke_started = False
if not pending_ids:
self._sync_brush_preview_exclusions()
return
target = self.brush_target
if target is None:
if self._undo_stack:
self._restore_state(self._undo_stack.pop())
self._sync_brush_preview_exclusions()
self._refresh_ui_state()
return
line_id, block_id = target
block = self._find_block_or_none(line_id, block_id)
if block is None:
if self._undo_stack:
self._restore_state(self._undo_stack.pop())
self._disable_brush("Целевой блок больше не существует.")
self.refresh_tree()
return
previous_active_line_id = self.active_line_id
self.active_line_id = line_id
if not self._confirm_reassignment(pending_ids):
if self._undo_stack:
self._restore_state(self._undo_stack.pop())
self.active_line_id = previous_active_line_id
self.canvas.select_points(pending_ids)
self._sync_brush_preview_exclusions()
self._refresh_ui_state()
return
added = self.document.append_points_to_block(line_id, block_id, pending_ids)
if added == 0:
if self._undo_stack:
self._restore_state(self._undo_stack.pop())
self.active_line_id = previous_active_line_id
self._sync_brush_preview_exclusions()
self._refresh_ui_state()
return
self._dirty = True
self.active_line_id = line_id
self.canvas.select_points(pending_ids)
self._sync_brush_preview_exclusions()
updated_block = self._find_block_or_none(line_id, block_id)
block_name = block.name if updated_block is None else updated_block.name
self._after_mapping_change(f"Кисть: в {block_name} добавлено LED: {added}.", selection_already_updated=True)
self._select_tree_block(line_id, block_id)
def _disable_brush(self, message: str) -> None:
action = self.actions.get("brush")
if action is not None:
action.setChecked(False)
self.canvas.set_brush_enabled(False)
self._brush_stroke_started = False
self._brush_append_to_selected = False
self._brush_pending_point_ids.clear()
self._brush_pending_point_set.clear()
self._sync_brush_preview_exclusions()
self.status.setText(message)
self._refresh_ui_state()
def _on_tree_selection(self) -> None:
selected_blocks = self._selected_tree_blocks()
if selected_blocks:
line_ids = {line_id for line_id, _block_id in selected_blocks}
if len(line_ids) == 1:
line_id = selected_blocks[0][0]
point_ids: list[int] = []
for _line_id, block_id in selected_blocks:
block = self._find_block_or_none(line_id, block_id)
if block is not None:
point_ids.extend(block.point_ids)
self.active_line_id = line_id
self.brush_target = selected_blocks[0] if len(selected_blocks) == 1 else None
self.canvas.set_active_line(self.active_line_id)
self.canvas.select_points(point_ids)
self._sync_brush_preview_exclusions()
return
item = self.tree.currentItem()
if item is None:
return
data = item.data(0, Qt.UserRole)
if not data:
return
if data[0] == "line":
line = self._get_line_or_none(data[1])
if line is None:
self.refresh_tree()
return
self.active_line_id = line.id
self.brush_target = None
self.canvas.set_active_line(self.active_line_id)
self.canvas.select_points([point_id for block in line.blocks for point_id in block.point_ids])
elif data[0] == "block":
block = self._find_block_or_none(data[1], data[2])
if block is None:
self.refresh_tree()
return
self.active_line_id = data[1]
self.brush_target = (data[1], data[2])
self.canvas.set_active_line(self.active_line_id)
self.canvas.select_points(block.point_ids)
self._sync_brush_preview_exclusions()
def _show_tree_menu(self, pos) -> None: # noqa: ANN001
item = self.tree.itemAt(pos)
if item is not None and not item.isSelected():
self.tree.clearSelection()
self.tree.setCurrentItem(item)
item.setSelected(True)
menu = QMenu(self)
merge_action = menu.addAction("Объединить блоки\tCtrl+M")
merge_action.setEnabled(self._can_merge_selected_tree_blocks())
rename_action = None
data = None
if item is not None:
data = item.data(0, Qt.UserRole)
if data:
if data[0] == "block":
rename_action = menu.addAction("Переименовать блок\tF2")
elif data[0] == "line":
rename_action = menu.addAction("Переименовать линию\tF2")
label = "Удалить выбранную фиксацию\tCtrl+Del"
if item is not None and data is not None:
if data[0] == "line":
label = "Удалить линию\tCtrl+Del"
elif data[0] == "block":
label = "Удалить блок\tCtrl+Del"
delete_action = menu.addAction(label)
delete_action.setEnabled(item is not None)
clear_all_action = menu.addAction("Удалить все фиксации\tShift+Del")
clear_all_action.setEnabled(bool(self.document.lines))
fit_action = menu.addAction("Показать все точки")
fit_action.setEnabled(bool(self.document.points))
chosen = menu.exec(self.tree.viewport().mapToGlobal(pos))
if chosen == merge_action:
self.merge_selected_blocks()
elif rename_action and chosen == rename_action:
if data[0] == "block":
line_id, block_id = data[1], data[2]
self.rename_block(line_id, block_id)
elif data[0] == "line":
line_id = data[1]
self.rename_line(line_id)
elif chosen == delete_action:
self.remove_selected_tree_item()
elif chosen == clear_all_action:
self.clear_all_assignments()
elif chosen == fit_action:
self.canvas.fit_to_content()
def _on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
data = item.data(0, Qt.UserRole)
if data:
if data[0] == "block":
line_id, block_id = data[1], data[2]
self.rename_block(line_id, block_id)
elif data[0] == "line":
line_id = data[1]
self.rename_line(line_id)
def rename_selected_tree_item(self) -> None:
item = self.tree.currentItem()
if item is not None:
data = item.data(0, Qt.UserRole)
if data:
if data[0] == "block":
line_id, block_id = data[1], data[2]
self.rename_block(line_id, block_id)
elif data[0] == "line":
line_id = data[1]
self.rename_line(line_id)
def rename_block(self, line_id: int, block_id: int) -> None:
block = self._find_block_or_none(line_id, block_id)
if block is None:
return
self._disable_shortcuts()
try:
new_name, ok = QInputDialog.getText(
self,
"Переименовать блок",
"Введите новое имя блока:",
QLineEdit.EchoMode.Normal,
block.name
)
finally:
self._enable_shortcuts()
if ok and new_name.strip() and new_name.strip() != block.name:
self._push_undo()
block.name = new_name.strip()
self._dirty = True
self.status.setText(f"Блок переименован в '{block.name}'.")
self.refresh_tree()
self._refresh_ui_state()
def rename_line(self, line_id: int) -> None:
line = self._get_line_or_none(line_id)
if line is None:
return
self._disable_shortcuts()
try:
new_name, ok = QInputDialog.getText(
self,
"Переименовать линию",
"Введите новое имя линии:",
QLineEdit.EchoMode.Normal,
line.name
)
finally:
self._enable_shortcuts()
if ok and new_name.strip() and new_name.strip() != line.name:
self._push_undo()
line.name = new_name.strip()
self._dirty = True
self.status.setText(f"Линия переименована в '{line.name}'.")
self.refresh_tree()
self._refresh_ui_state()
def merge_selected_blocks(self) -> None:
selected_blocks = self._selected_tree_blocks()
if len(selected_blocks) < 2:
self.status.setText("Выберите два или больше блока для объединения.")
self._refresh_ui_state()
return
line_ids = {line_id for line_id, _block_id in selected_blocks}
if len(line_ids) != 1:
show_information(self, "Разные линии", "Объединять можно только блоки одной линии.")
return
line_id = selected_blocks[0][0]
block_ids = [block_id for _line_id, block_id in selected_blocks]
self._push_undo()
merged = self.document.merge_blocks(line_id, block_ids)
self._dirty = True
self.active_line_id = line_id
self.brush_target = (line_id, merged.id)
self.canvas.select_points(merged.point_ids)
self._after_mapping_change(
f"Объединены блоки: {len(block_ids)} -> {merged.name}, LED: {len(merged.point_ids)}. "
f"{self._unfolding_summary(line_id)}",
selection_already_updated=True,
)
def _show_table_menu(self, pos) -> None: # noqa: ANN001
item = self.table.itemAt(pos)
if item is None:
self.table.clearSelection()
self.canvas.select_points([])
elif item.row() not in self._selected_table_rows():
self.table.selectRow(item.row())
menu = QMenu(self)
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
delete_action.setEnabled(self._selected_table_has_assignment())
delete_points_action = menu.addAction("Удалить выбранные точки")
delete_points_action.setEnabled(bool(self._selected_table_point_ids()))
clear_selection_action = menu.addAction("Очистить выбор")
clear_selection_action.setEnabled(bool(self._selected_table_point_ids()))
chosen = menu.exec(self.table.viewport().mapToGlobal(pos))
if chosen == delete_action:
self.canvas.select_points(self._selected_table_point_ids())
self.clear_selected_point_assignments()
elif chosen == delete_points_action:
self.canvas.select_points(self._selected_table_point_ids())
self.delete_selected_points()
elif chosen == clear_selection_action:
self.canvas.select_points([])
def _on_selection_changed(self, point_ids: list[int]) -> None:
self._updating_table = True
sort_column = self.table.horizontalHeader().sortIndicatorSection()
sort_order = self.table.horizontalHeader().sortIndicatorOrder()
sorting_enabled = self.table.isSortingEnabled()
self.table.setUpdatesEnabled(False)
self.table.setSortingEnabled(False)
self.table.blockSignals(True)
try:
self.table.setRowCount(len(point_ids))
for row, point_id in enumerate(point_ids):
point = self.document.get_point(point_id)
values = [
"-" if point.line_index is None else point.line_index,
self._format_coordinate(point.x),
self._format_coordinate(point.y),
point.row,
point.col,
"-" if point.line_id is None else point.line_id,
"-" if point.block_id is None else point.block_id,
point.source_id,
]
for col, value in enumerate(values):
item = SortableTableItem(str(value))
item.setData(Qt.UserRole, point.uid)
item.setData(SORT_ROLE, self._table_sort_value(value))
if col in {X_COLUMN, Y_COLUMN}:
item.setToolTip("Можно редактировать координату LED.")
else:
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row, col, item)
self.table.setSortingEnabled(sorting_enabled)
if sorting_enabled and sort_column >= 0:
self.table.sortItems(sort_column, sort_order)
finally:
self.table.blockSignals(False)
if self.table.isSortingEnabled() != sorting_enabled:
self.table.setSortingEnabled(sorting_enabled)
self.table.setUpdatesEnabled(True)
self._updating_table = False
self.selection_label.setText(self._selection_summary(point_ids))
self.status.setText(self._selection_summary(point_ids))
self._refresh_ui_state(point_ids)
def _on_table_item_changed(self, item: QTableWidgetItem) -> None:
if self._updating_table or item.column() not in {X_COLUMN, Y_COLUMN}:
return
point_id = item.data(Qt.UserRole)
if not isinstance(point_id, int) or not (0 <= point_id < len(self.document.points)):
return
point = self.document.get_point(point_id)
try:
value = float(item.text().replace(",", "."))
except ValueError:
self._restore_table_coordinate_item(item, point)
self.status.setText("Координата должна быть числом.")
return
old_value = point.x if item.column() == X_COLUMN else point.y
if abs(old_value - value) < 1e-9:
item.setData(SORT_ROLE, self._table_sort_value(value))
return
self._push_undo()
self.document.update_point_position(
point.uid,
x=value if item.column() == X_COLUMN else None,
y=value if item.column() == Y_COLUMN else None,
)
self._dirty = True
blocker = QSignalBlocker(self.table)
item.setText(self._format_coordinate(value))
item.setData(SORT_ROLE, self._table_sort_value(value))
del blocker
self.canvas.set_document(self.document, reset_view=False)
self.canvas.select_points([point.uid])
self._on_selection_changed([point.uid])
self.status.setText(f"LED ID {point.source_id}: координаты обновлены.")
def _restore_table_coordinate_item(self, item: QTableWidgetItem, point) -> None: # noqa: ANN001
value = point.x if item.column() == X_COLUMN else point.y
blocker = QSignalBlocker(self.table)
item.setText(self._format_coordinate(value))
item.setData(SORT_ROLE, self._table_sort_value(value))
del blocker
def _select_rows_on_canvas(self) -> None:
if self._updating_table:
return
ids = []
selection_model = self.table.selectionModel()
rows = (
[index.row() for index in selection_model.selectedRows()]
if selection_model is not None
else sorted({item.row() for item in self.table.selectedItems()})
)
for row in rows:
item = self.table.item(row, UID_COLUMN)
if item is not None:
ids.append(int(item.data(Qt.UserRole)))
if ids:
self.canvas.select_points(ids)
else:
self.canvas.select_points([])
def _require_active_line(self) -> int | None:
if self.active_line_id is None:
self.status.setText("Создайте или выберите линию перед назначением LED.")
self._refresh_ui_state()
return None
return self.active_line_id
def _after_mapping_change(self, message: str, *, selection_already_updated: bool = False) -> None:
self.canvas.refresh_styles()
self.refresh_tree()
if not selection_already_updated:
self._on_selection_changed(self.canvas.selected_point_ids())
self.status.setText(message)
self._refresh_ui_state()
def undo_last_change(self) -> None:
if not self._undo_stack:
self.status.setText("Нет действий для отмены.")
self._refresh_ui_state()
return
self._redo_stack.append(self._capture_state())
self._restore_state(self._undo_stack.pop())
self.status.setText("Последнее изменение отменено.")
self._refresh_ui_state()
def redo_last_change(self) -> None:
if not self._redo_stack:
self.status.setText("Нет действий для повтора.")
self._refresh_ui_state()
return
self._undo_stack.append(self._capture_state())
if len(self._undo_stack) > 20:
self._undo_stack.pop(0)
self._restore_state(self._redo_stack.pop())
self.status.setText("Изменение повторено.")
self._refresh_ui_state()
def _sync_canvas_to_active_line(self) -> None:
line = self._get_line_or_none(self.active_line_id)
if line is None:
self.canvas.set_active_line(None)
self.canvas.select_points([])
return
self.canvas.set_active_line(self.active_line_id)
self.canvas.select_points([point_id for block in line.blocks for point_id in block.point_ids])
def _restore_active_line(self, line: LedLine | None) -> None:
if line is not None and line in self.document.lines:
self.active_line_id = line.id
elif self.active_line_id is not None and self._get_line_or_none(self.active_line_id) is not None:
return
else:
self.active_line_id = self.document.lines[0].id if self.document.lines else None
def _selected_table_point_ids(self) -> list[int]:
rows = sorted({item.row() for item in self.table.selectedItems()})
point_ids: list[int] = []
for row in rows:
item = self.table.item(row, UID_COLUMN)
if item is not None:
point_ids.append(int(item.data(Qt.UserRole)))
return point_ids
def _selected_table_rows(self) -> set[int]:
return {item.row() for item in self.table.selectedItems()}
def _selected_table_has_assignment(self) -> bool:
for point_id in self._selected_table_point_ids():
point = self.document.get_point(point_id)
if point.line_id is not None or point.block_id is not None:
return True
return False
def _selected_tree_blocks(self) -> list[tuple[int, int]]:
blocks: list[tuple[int, int]] = []
for item in self.tree.selectedItems():
data = item.data(0, Qt.UserRole)
if data and data[0] == "block" and self._block_exists(data[1], data[2]):
blocks.append((data[1], data[2]))
order: dict[tuple[int, int], int] = {}
for line in self.document.lines:
for block in line.blocks:
order[(line.id, block.id)] = len(order)
return sorted(set(blocks), key=lambda block_key: order.get(block_key, 10**9))
def _can_merge_selected_tree_blocks(self) -> bool:
selected_blocks = self._selected_tree_blocks()
return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1
def _find_block(self, line_id: int, block_id: int) -> LedBlock:
line = self.document.get_line(line_id)
for block in line.blocks:
if block.id == block_id:
return block
raise KeyError(block_id)
def _find_block_or_none(self, line_id: int, block_id: int) -> LedBlock | None:
line = self._get_line_or_none(line_id)
if line is None:
return None
for block in line.blocks:
if block.id == block_id:
return block
return None
def _get_line_or_none(self, line_id: int) -> LedLine | None:
for line in self.document.lines:
if line.id == line_id:
return line
return None
def _line_exists(self, line_id: int) -> bool:
return self._get_line_or_none(line_id) is not None
def _block_exists(self, line_id: int, block_id: int) -> bool:
return self._find_block_or_none(line_id, block_id) is not None
def _refresh_ui_state(self, point_ids: list[int] | None = None) -> None:
if point_ids is None:
point_ids = self.canvas.selected_point_ids()
has_points = bool(self.document.points)
has_lines = bool(self.document.lines)
has_background = self.document.background.is_enabled()
has_loaded_background = has_background and self.canvas.background_loaded()
has_visible_background = has_loaded_background and self.document.background.visible
has_active_line = self._get_line_or_none(self.active_line_id) is not None
has_selection = bool(point_ids)
selected_has_assignment = any(
self.document.get_point(point_id).line_id is not None
or self.document.get_point(point_id).block_id is not None
for point_id in point_ids
)
assigned_count = sum(1 for point in self.document.points if point.line_id is not None)
block_count = sum(len(line.blocks) for line in self.document.lines)
file_name = "-" if self.document.path is None else self.document.path.name
dirty_mark = "*" if self._dirty else ""
self.summary_label.setText(
f"Файл: {file_name}{dirty_mark} | Точек: {len(self.document.points)} | "
f"Линий: {len(self.document.lines)}/{MAX_LINES} | "
f"Блоков: {block_count} | Назначено: {assigned_count}"
)
active_line = self._get_line_or_none(self.active_line_id)
if active_line is None:
self.active_label.setText("Активная линия: -")
self.active_label.setStyleSheet("")
else:
self.active_label.setText(f"Активная линия: {active_line.name}")
self.active_label.setStyleSheet(
f"border-left: 8px solid {active_line.color}; padding-left: 6px; font-weight: 600;"
)
self.canvas.set_active_line(self.active_line_id if active_line is not None else None)
self.selection_label.setText(self._selection_summary(point_ids))
state = {
"save": has_points and self._dirty,
"save_as": has_points,
"generate_script": has_points,
"undo": bool(self._undo_stack),
"redo": bool(self._redo_stack),
"add_line": has_points and len(self.document.lines) < MAX_LINES,
"add_led": True,
"add_block": has_points and has_active_line,
"auto_blocks": has_points and has_active_line and has_selection,
"clockwise": has_active_line,
"counterclockwise": has_points and len(point_ids) == 1,
"rebuild_route": has_active_line,
"brush": has_points,
"delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(),
"delete_points": has_selection,
"clear_all": has_lines,
"clear_selection": has_selection,
"fit_view": has_points or has_visible_background,
"grid": True,
"background": True,
"background_visible": has_loaded_background,
"background_edit": has_visible_background,
"background_clear": has_background,
"background_reset_scale": has_loaded_background,
"toggle_sidebar": True,
}
for key, enabled in state.items():
action = self.actions.get(key)
if action is not None:
action.setEnabled(enabled)
button = self.side_buttons.get(key)
if button is not None:
button.setEnabled(enabled)
brush_action = self.actions.get("brush")
if brush_action is not None and not state["brush"] and brush_action.isChecked():
brush_action.setChecked(False)
self.canvas.set_brush_enabled(False)
self._brush_stroke_started = False
background_edit_action = self.actions.get("background_edit")
if background_edit_action is not None and not has_visible_background and background_edit_action.isChecked():
background_edit_action.setChecked(False)
self.canvas.set_background_edit_mode(False)
background_visible_action = self.actions.get("background_visible")
if background_visible_action is not None:
blocker = QSignalBlocker(background_visible_action)
background_visible_action.setChecked(bool(has_background and self.document.background.visible))
del blocker
self._refresh_block_append_buttons(point_ids)
self._refresh_window_title()
def _refresh_block_append_buttons(self, point_ids: list[int] | None = None) -> None:
if point_ids is None:
point_ids = self.canvas.selected_point_ids()
selected_set = set(point_ids)
has_selection = bool(selected_set)
for (line_id, block_id), button in self.block_append_buttons.items():
block = self._find_block_or_none(line_id, block_id)
if block is None:
button.setEnabled(False)
continue
block_point_set = set(block.point_ids) if len(selected_set) <= len(block.point_ids) else None
button.setEnabled(
has_selection
and (
block_point_set is None
or not selected_set.issubset(block_point_set)
)
)
def _on_application_focus_changed(self, _old: QWidget | None, _new: QWidget | None) -> None:
self._refresh_focus_sensitive_actions()
def _refresh_focus_sensitive_actions(self) -> None:
point_ids = self.canvas.selected_point_ids()
selected_has_assignment = any(
self.document.get_point(point_id).line_id is not None
or self.document.get_point(point_id).block_id is not None
for point_id in point_ids
)
enabled = (bool(point_ids) and selected_has_assignment) or self._tree_has_focus_with_item()
action = self.actions.get("delete")
if action is not None:
action.setEnabled(enabled)
button = self.side_buttons.get("delete")
if button is not None:
button.setEnabled(enabled)
def _refresh_window_title(self) -> None:
file_name = "без файла" if self.document.path is None else self.document.path.name
dirty_mark = "*" if self._dirty else ""
self.setWindowTitle(f"{dirty_mark}{file_name} - LED Mapper Editor")
def _tree_has_focus_with_item(self) -> bool:
focus = self.focusWidget()
return (
focus is not None
and (focus is self.tree or self.tree.isAncestorOf(focus))
and self.tree.currentItem() is not None
)
def _current_block_target(self) -> tuple[int, int] | None:
if self.brush_target is not None and self._find_block_or_none(*self.brush_target) is not None:
return self.brush_target
return self._tree_block_target()
def _tree_block_target(self) -> tuple[int, int] | None:
item = self.tree.currentItem()
if item is None:
return None
data = item.data(0, Qt.UserRole)
if data and data[0] == "block" and self._find_block_or_none(data[1], data[2]) is not None:
return data[1], data[2]
return None
def _current_line_target(self) -> int | None:
item = self.tree.currentItem()
if item is None:
return self.active_line_id
data = item.data(0, Qt.UserRole)
if data and data[0] == "line" and self._line_exists(data[1]):
return data[1]
if data and data[0] == "block" and self._line_exists(data[1]):
return data[1]
return self.active_line_id
def _selected_block_target(self) -> tuple[int, int] | None:
for point_id in self.canvas.selected_point_ids():
point = self.document.get_point(point_id)
if point.line_id is None or point.block_id is None:
continue
if self._find_block_or_none(point.line_id, point.block_id) is not None:
return point.line_id, point.block_id
return None
def _initial_brush_target(self) -> tuple[int, int] | None:
return self._selected_block_target() or self._current_block_target()
def _brush_insert_context(self) -> tuple[int | None, int | None]:
tree_block = self._tree_block_target()
if tree_block is not None:
return tree_block
selected_block = self._selected_block_target()
if selected_block is not None:
return selected_block
return self._current_line_target(), None
def _create_brush_target(self, point_id: int) -> tuple[int, int] | None:
context_line_id, after_block_id = self._brush_insert_context()
line = self._get_line_or_none(context_line_id)
if line is None:
point = self.document.get_point(point_id)
line = self._get_line_or_none(point.line_id) if point.line_id is not None else None
if line is None and self.document.lines:
line = self.document.lines[0]
if line is None:
if len(self.document.lines) >= MAX_LINES:
self.status.setText(f"Нельзя создать блок: лимит линий {MAX_LINES}.")
return None
line = self.document.add_line()
block = self.document.add_block(line.id, [], after_block_id=after_block_id)
self.active_line_id = line.id
self.brush_target = (line.id, block.id)
self.refresh_tree()
self._select_tree_block(line.id, block.id)
self._sync_brush_preview_exclusions()
self.status.setText(f"Кисть: создан {block.name}, добавляйте LED.")
return self.brush_target
def _sync_brush_preview_exclusions(self) -> None:
excluded_ids = set(self._brush_pending_point_ids)
target = self.brush_target
if target is not None:
block = self._find_block_or_none(*target)
if block is not None:
excluded_ids.update(block.point_ids)
self.canvas.set_brush_excluded_point_ids(excluded_ids)
def _push_undo(self) -> None:
self._push_undo_state(self._capture_state())
def _push_undo_state(self, state: tuple[MappingDocument, int | None, list[int], bool]) -> None:
self._undo_stack.append(state)
if len(self._undo_stack) > 20:
self._undo_stack.pop(0)
self._redo_stack.clear()
self._refresh_ui_state()
def _capture_state(self) -> tuple[MappingDocument, int | None, list[int], bool]:
return (
deepcopy(self.document),
self.active_line_id,
self.canvas.selected_point_ids(),
self._dirty,
)
def _disable_shortcuts(self) -> None:
self._temp_shortcuts = {}
for key, action in self.actions.items():
self._temp_shortcuts[key] = action.shortcut()
action.setShortcut(QKeySequence())
def _enable_shortcuts(self) -> None:
if hasattr(self, "_temp_shortcuts"):
for key, shortcut in self._temp_shortcuts.items():
if key in self.actions:
self.actions[key].setShortcut(shortcut)
del self._temp_shortcuts
def _restore_state(self, state: tuple[MappingDocument, int | None, list[int], bool]) -> None:
document, active_line_id, selected_point_ids, dirty = state
self.document = document
self.active_line_id = active_line_id
self._dirty = dirty
self.canvas.set_document(self.document, reset_view=False)
self.canvas.set_active_line(self.active_line_id)
self.refresh_tree()
self.canvas.select_points([point_id for point_id in selected_point_ids if 0 <= point_id < len(self.document.points)])
def _selection_summary(self, point_ids: list[int]) -> str:
if not point_ids:
return "Выбранные LED: 0"
assigned = 0
lines: set[int] = set()
for point_id in point_ids:
point = self.document.get_point(point_id)
if point.line_id is not None:
assigned += 1
lines.add(point.line_id)
unassigned = len(point_ids) - assigned
line_text = "-" if not lines else ", ".join(str(line_id) for line_id in sorted(lines))
return f"Выбранные LED: {len(point_ids)} | назначено: {assigned} | свободно: {unassigned} | линии: {line_text}"
def _confirm_reassignment(self, point_ids: list[int]) -> bool:
foreign_points = [
point_id
for point_id in point_ids
if self.document.get_point(point_id).line_id is not None
and self.document.get_point(point_id).line_id != self.active_line_id
]
assigned_points = [
point_id
for point_id in point_ids
if self.document.get_point(point_id).line_id is not None
or self.document.get_point(point_id).block_id is not None
]
if not foreign_points and not assigned_points:
return True
if assigned_points and not foreign_points:
answer = ask_question(
self,
"Перезаписать блок",
f"{len(assigned_points)} выбранных LED уже находятся в блоках. Перезаписать их новым блоком?",
)
return answer == QMessageBox.StandardButton.Yes
answer = ask_question(
self,
"Переназначить LED",
f"{len(foreign_points)} выбранных LED уже находятся в других линиях. Переназначить их в активную линию?",
)
return answer == QMessageBox.StandardButton.Yes
def closeEvent(self, event) -> None: # noqa: ANN001
if self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
event.accept()
else:
event.ignore()
def _confirm_auto_blocks(self, point_ids: list[int], preview: list[list[int]]) -> bool:
if not preview:
return False
assigned = sum(1 for point_id in point_ids if self.document.get_point(point_id).line_id is not None)
if assigned == 0 and len(preview) == 1:
return True
answer = ask_question(
self,
"Создать автоблоки",
f"Будет создано блоков: {len(preview)}, LED: {sum(len(group) for group in preview)}. "
f"Уже назначенных LED в выборе: {assigned}. Продолжить?",
)
return answer == QMessageBox.StandardButton.Yes
def _confirm_delete(self, kind: str, name: str, affected: int) -> bool:
answer = ask_question(
self,
"Подтвердите удаление",
f"Удалить {kind} '{name}' и снять фиксацию с {affected} LED?",
)
return answer == QMessageBox.StandardButton.Yes
def _confirm_delete_points(self, count: int) -> bool:
answer = ask_question(
self,
"Удалить точки",
f"Полностью удалить выбранные LED-точки из схемы: {count}?",
)
return answer == QMessageBox.StandardButton.Yes
def _confirm_discard_changes(self, message: str | None = None) -> bool:
if not self._dirty:
return True
answer = ask_question(
self,
"Есть несохраненные изменения",
message or "Открыть другой файл и потерять несохраненные изменения текущей карты?",
)
return answer == QMessageBox.StandardButton.Yes
@staticmethod
def _line_count(line: LedLine) -> int:
return sum(len(block.point_ids) for block in line.blocks)
@staticmethod
def _table_sort_value(value) -> tuple[int, int | float | str]: # noqa: ANN001
if value == "-":
return 1, 0
if isinstance(value, (int, float)):
return 0, value
text = str(value)
if text.lstrip("-").isdigit():
return 0, int(text)
try:
return 0, float(text.replace(",", "."))
except ValueError:
pass
return 0, text
@staticmethod
def _format_coordinate(value: float) -> str:
text = f"{value:.3f}".rstrip("0").rstrip(".")
return text if text else "0"
def _line_summary(self, line: LedLine) -> str:
return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}"
def _unfolding_summary(self, line_id: int) -> str:
line = self.document.get_line(line_id)
count = self._line_count(line)
index_range = "-" if count == 0 else f"0-{count - 1}"
return f"{line.name}: развертка {len(line.blocks)} блоков, {count} LED, индекс {index_range}"
def _document_summary(self) -> str:
block_count = sum(len(line.blocks) for line in self.document.lines)
assigned_count = sum(1 for point in self.document.points if point.line_id is not None)
return f"линий: {len(self.document.lines)}/{MAX_LINES}, блоков: {block_count}, назначено LED: {assigned_count}"