LED-mapper/main_window.py

1484 lines
67 KiB
Python
Raw 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
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}"