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