feat: add LED copy and paste in edit mode

- добавить команды копирования и вставки выбранных светодиодов в режиме редактирования
- назначить на новые команды настраиваемые сочетания Ctrl+C и Ctrl+V
- создавать свободные копии с уникальными идентификаторами и каскадным смещением по шагу сетки
- сохранять взаимное расположение и пользовательские поля копируемых точек
- объединить вставку группы в одну операцию Undo с восстановлением выделения через Redo
- не перехватывать стандартное копирование и вставку при редактировании текста и координат
- добавить пункты меню, иконки, документацию и проверки, расширив QA до 164 сценариев
This commit is contained in:
Данил Ярош 2026-09-09 08:30:30 +03:00
parent 0d4552ef35
commit 0b24cbd83c
6 changed files with 290 additions and 7 deletions

View File

@ -89,6 +89,8 @@ python -m venv .venv
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
В режиме редактирования выбранные светодиоды можно скопировать через `Ctrl+C` и вставить через `Ctrl+V`. Вставленные LED остаются свободными, сохраняют взаимное расположение и пользовательские поля исходных точек. Первая копия смещается на один шаг сетки вправо и вниз, а следующие вставки образуют каскад. Вся группа вставляется одной операцией Undo и сразу становится текущим выделением.
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
## Горячие клавиши
@ -101,6 +103,8 @@ python -m venv .venv
| Сгенерировать скрипт | `Ctrl+G` |
| Отменить | `Ctrl+Z` |
| Повторить | `Ctrl+Y` |
| Копировать выбранные LED | `Ctrl+C` |
| Вставить скопированные LED | `Ctrl+V` |
| Удалить фиксацию | `Del` |
| Удалить выбранные точки или блоки | `Ctrl+Del` |
| Удалить все | `Ctrl+Shift+Del` |

View File

@ -10,6 +10,8 @@ LUCIDE_PATHS = {
"arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
"brush": '<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.01 0 1.1-.89 2-2 2 1.4 1.4 3.67 1.4 5.07 0l2.94-2.94a2.93 2.93 0 0 0-4.14-4.14z"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"clipboard-paste": '<rect width="8" height="4" x="8" y="2" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v5M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/><path d="m16 19 3-3-3-3"/><path d="M19 16h-7"/>',
"copy": '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
"eraser": '<path d="m7 21-4.3-4.3c-.9-.9-.9-2.4 0-3.3L14 2l8 8-11 11Z"/><path d="M22 21H7"/><path d="m5 11 8 8"/>',
"eye": '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
"file-plus": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/>',

View File

@ -54,7 +54,7 @@ try:
show_information,
show_warning,
)
from .models import LedBlock, LedLine, MappingDocument
from .models import LedBlock, LedLine, LedPoint, MappingDocument
from .panel_mapping_generator import generate_panel_mapping_from_document
from .settings import DEFAULT_SHORTCUTS, AppSettings
from .settings_dialog import SettingsDialog
@ -73,7 +73,7 @@ except ImportError:
show_information,
show_warning,
)
from models import LedBlock, LedLine, MappingDocument
from models import LedBlock, LedLine, LedPoint, MappingDocument
from panel_mapping_generator import generate_panel_mapping_from_document
from settings import DEFAULT_SHORTCUTS, AppSettings
from settings_dialog import SettingsDialog
@ -204,6 +204,8 @@ class MainWindow(QMainWindow):
self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
self._pending_background_undo_state: tuple[MappingDocument, int | None, list[int], bool] | None = None
self._copied_led_points: list[LedPoint] = []
self._paste_sequence = 0
self.settings = AppSettings.load()
self.document.grid_step_mm = self.settings.grid_step_mm
apply_app_theme(self.settings.theme)
@ -384,6 +386,22 @@ class MainWindow(QMainWindow):
brush_action.setCheckable(True)
add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo)
add_action("redo", "Повторить", self.redo_last_change, "redo-2", QKeySequence.Redo)
add_action(
"copy_leds",
"Копировать светодиоды",
self.copy_selected_leds,
"copy",
QKeySequence.Copy,
"Скопировать выбранные LED в режиме редактирования",
)
add_action(
"paste_leds",
"Вставить светодиоды",
self.paste_copied_leds,
"clipboard-paste",
QKeySequence.Paste,
"Вставить свободные копии LED со смещением на шаг сетки",
)
self.delete_action = add_action(
"delete",
"Удалить фиксацию",
@ -478,6 +496,9 @@ class MainWindow(QMainWindow):
edit_menu.addAction(self.actions["undo"])
edit_menu.addAction(self.actions["redo"])
edit_menu.addSeparator()
edit_menu.addAction(self.actions["copy_leds"])
edit_menu.addAction(self.actions["paste_leds"])
edit_menu.addSeparator()
edit_menu.addAction(self.actions["delete"])
edit_menu.addAction(self.actions["delete_points"])
edit_menu.addAction(self.actions["clear_all"])
@ -1120,9 +1141,13 @@ class MainWindow(QMainWindow):
self.canvas.set_instant_space_pan(self.settings.instant_space_pan)
def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802
if event.type() == QEvent.Type.ShortcutOverride and self._is_tree_bulk_delete_event(event):
event.accept()
return self.remove_selected_tree_item()
if event.type() == QEvent.Type.ShortcutOverride:
if self._text_input_owns_copy_paste(event):
event.accept()
return True
if self._is_tree_bulk_delete_event(event):
event.accept()
return self.remove_selected_tree_item()
if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat():
if self._trigger_layout_independent_shortcut(event):
return True
@ -1147,6 +1172,8 @@ class MainWindow(QMainWindow):
modifiers = event.modifiers()
if isinstance(focus, QKeySequenceEdit):
return False
if self._text_input_owns_copy_paste(event):
return False
text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit))
if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)):
return False
@ -1161,6 +1188,13 @@ class MainWindow(QMainWindow):
return True
return False
@staticmethod
def _text_input_owns_copy_paste(event) -> bool: # noqa: ANN001
focus = QApplication.focusWidget()
if not isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit)):
return False
return event.matches(QKeySequence.StandardKey.Copy) or event.matches(QKeySequence.StandardKey.Paste)
def _physical_key_sequence(self, event) -> str: # noqa: ANN001
key_name = physical_key_name(event)
if not key_name:
@ -1652,6 +1686,56 @@ class MainWindow(QMainWindow):
self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.")
self._refresh_ui_state()
def copy_selected_leds(self) -> bool:
if not self._edit_mode_is_enabled():
self.status.setText("Копирование LED доступно только в режиме редактирования.")
self._refresh_ui_state()
return False
point_ids = self.canvas.selected_point_ids()
if not point_ids:
self.status.setText("Выберите светодиоды для копирования.")
self._refresh_ui_state()
return False
self._copied_led_points = [deepcopy(self.document.get_point(point_id)) for point_id in point_ids]
self._paste_sequence = 0
self.status.setText(f"Скопировано светодиодов: {len(self._copied_led_points)}.")
self._refresh_ui_state(point_ids)
return True
def paste_copied_leds(self) -> bool:
if not self._edit_mode_is_enabled():
self.status.setText("Вставка LED доступна только в режиме редактирования.")
self._refresh_ui_state()
return False
if not self._copied_led_points:
self.status.setText("Сначала скопируйте выбранные светодиоды.")
self._refresh_ui_state()
return False
self._push_undo()
self._paste_sequence += 1
offset = float(self.document.grid_step_in_document_units()) * self._paste_sequence
pasted = self.document.add_point_copies(
self._copied_led_points,
offset_x=offset,
offset_y=-offset,
)
pasted_ids = [point.uid for point in pasted]
self._dirty = True
self.canvas.set_document(self.document, reset_view=False)
self.canvas.select_points(pasted_ids)
self.refresh_tree()
self._on_selection_changed(pasted_ids)
self.status.setText(
f"Вставлено свободных светодиодов: {len(pasted)}. "
f"Смещение: {self._paste_sequence} шаг(а) сетки вправо и вниз."
)
self._refresh_ui_state(pasted_ids)
return True
def _edit_mode_is_enabled(self) -> bool:
action = self.actions.get("toggle_edit_mode")
return bool(action is not None and action.isChecked())
def edit_point_coordinates(self, point_id: int) -> None:
if not 0 <= point_id < len(self.document.points):
return
@ -2683,6 +2767,7 @@ class MainWindow(QMainWindow):
has_visible_background = has_loaded_background and self.document.background.visible
has_active_line = self._get_line_or_none(self.active_line_id) is not None
has_selection = bool(point_ids)
edit_mode_enabled = self._edit_mode_is_enabled()
selected_has_assignment = any(
self.document.get_point(point_id).line_id is not None
or self.document.get_point(point_id).block_id is not None
@ -2716,6 +2801,8 @@ class MainWindow(QMainWindow):
"generate_script": has_points,
"undo": bool(self._undo_stack),
"redo": bool(self._redo_stack),
"copy_leds": edit_mode_enabled and has_selection,
"paste_leds": edit_mode_enabled and bool(self._copied_led_points),
"add_line": has_points and len(self.document.lines) < MAX_LINES,
"add_led": True,
"add_block": has_points and has_active_line,

View File

@ -10,6 +10,7 @@ import os
import re
import tempfile
from collections import deque
from copy import deepcopy
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
@ -583,6 +584,37 @@ class MappingDocument:
self.points.append(point)
return point
def add_point_copies(
self,
templates: list[LedPoint],
*,
offset_x: float,
offset_y: float,
) -> list[LedPoint]:
if not templates:
return []
next_source_id = max((point.source_id for point in self.points), default=-1) + 1
copies: list[LedPoint] = []
for template in list(templates):
x = float(template.x) + float(offset_x)
y = float(template.y) + float(offset_y)
row, col = self._grid_cell_for_coordinate(x, y)
point = LedPoint(
uid=len(self.points),
source_id=next_source_id,
x=x,
y=y,
row=row,
col=col,
coordinate_unit=self._coordinate_unit_for_save(),
manual=True,
extra_payload=deepcopy(template.extra_payload),
)
self.points.append(point)
copies.append(point)
next_source_id += 1
return copies
def update_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None:
point = self.get_point(point_id)
if x is not None:

View File

@ -10,6 +10,7 @@ import subprocess
import sys
import tempfile
import time
from copy import deepcopy
from pathlib import Path
from typing import Callable
@ -42,7 +43,7 @@ try:
from PySide6.QtCore import QPoint, QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QImage, QKeySequence
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QLineEdit, QMessageBox, QToolBar
except ImportError:
canvas_module = None
LedCanvas = None
@ -73,6 +74,7 @@ except ImportError:
QApplication = None
QDialogButtonBox = None
QGraphicsItem = None
QLineEdit = None
QTest = None
QMessageBox = None
QColor = None
@ -110,6 +112,7 @@ def main() -> None:
test_legacy_line_without_block_id_is_preserved,
test_points_without_grid_fields_load,
test_manual_led_save_load_roundtrip,
test_add_point_copies_preserves_geometry_and_metadata,
test_coordinate_unit_and_exact_positions_survive_span_change,
test_failed_atomic_save_preserves_existing_file,
test_grid_step_roundtrip_and_meter_layout,
@ -144,6 +147,7 @@ def main() -> None:
test_line_renaming_and_tree_shortcuts,
test_new_file_action,
test_scheme_editing_mode_actions,
test_copy_paste_selected_leds_in_edit_mode,
test_menu_bar_tools_buttons,
test_export_mapping_to_image,
test_save_preserves_unknown_source_fields,
@ -193,6 +197,7 @@ def main() -> None:
test_theme_resolution_and_canvas_apply,
test_menu_disabled_items_are_visually_distinct,
test_shortcut_settings_are_editable_and_layout_independent,
test_copy_paste_shortcuts_preserve_text_editing,
test_standard_dialog_buttons_are_localized,
test_lucide_icon_names_are_declared,
test_table_uses_single_line_index_column,
@ -668,6 +673,37 @@ def test_manual_led_save_load_roundtrip() -> None:
assert loaded.points[0].block_id is None
def test_add_point_copies_preserves_geometry_and_metadata() -> None:
doc = MappingDocument()
doc.coordinate_unit = "mm"
doc.grid_step_mm = 10.0
doc.points = [
LedPoint(0, 10, 0.0, 0.0, 0, 0, extra_payload={"custom": {"value": 1}}),
LedPoint(1, 20, 10.0, 5.0, 1, 1, line_id=1, block_id=1),
]
copies = doc.add_point_copies(doc.points[:], offset_x=20.0, offset_y=-10.0)
assert [point.uid for point in copies] == [2, 3]
assert [point.source_id for point in copies] == [21, 22]
assert [(point.x, point.y) for point in copies] == [(20.0, -10.0), (30.0, -5.0)]
assert copies[1].x - copies[0].x == 10.0
assert copies[1].y - copies[0].y == 5.0
assert all(point.manual for point in copies)
assert all(point.line_id is None and point.block_id is None for point in copies)
assert copies[0].extra_payload == doc.points[0].extra_payload
copies[0].extra_payload["custom"]["value"] = 2
assert doc.points[0].extra_payload["custom"]["value"] == 1
with tempfile.TemporaryDirectory() as temp_dir:
saved = Path(temp_dir) / "copied-points.json"
doc.save(saved)
reloaded = MappingDocument()
reloaded.load(saved)
assert [(point.x, point.y) for point in reloaded.points[2:4]] == [(20.0, -10.0), (30.0, -5.0)]
assert [point.source_id for point in reloaded.points[2:4]] == [21, 22]
assert all(point.manual for point in reloaded.points[2:4])
assert reloaded.points[2].extra_payload["custom"]["value"] == 2
def test_coordinate_unit_and_exact_positions_survive_span_change() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
source = Path(temp_dir) / "legacy_without_unit.json"
@ -1497,6 +1533,71 @@ def test_scheme_editing_mode_actions() -> None:
window.close()
def test_copy_paste_selected_leds_in_edit_mode() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
doc = MappingDocument()
doc.coordinate_unit = "mm"
doc.grid_step_mm = 10.0
doc.points = [
LedPoint(0, 0, 10.0, 20.0, 0, 0, extra_payload={"label": "A"}),
LedPoint(1, 1, 15.0, 10.0, 1, 1, extra_payload={"label": "B"}),
LedPoint(2, 2, 30.0, 40.0, 2, 2),
]
line = doc.add_line()
doc.add_block(line.id, [0, 1])
window.document = doc
window.canvas.set_document(doc, reset_view=True)
window._refresh_ui_state()
window.canvas.select_points([0, 1])
assert not window.actions["copy_leds"].isEnabled()
assert not window.actions["paste_leds"].isEnabled()
edit_action = window.actions["toggle_edit_mode"]
edit_action.setChecked(True)
window.toggle_edit_mode()
assert window.actions["copy_leds"].isEnabled()
assert not window.actions["paste_leds"].isEnabled()
assert window.copy_selected_leds()
assert window.actions["paste_leds"].isEnabled()
assert window.paste_copied_leds()
assert len(window.document.points) == 5
assert window.canvas.selected_point_ids() == [3, 4]
first_paste = window.document.points[3:5]
assert [(point.x, point.y) for point in first_paste] == [(20.0, 10.0), (25.0, 0.0)]
assert all(point.manual for point in first_paste)
assert all(point.line_id is None and point.block_id is None for point in first_paste)
assert [point.source_id for point in first_paste] == [3, 4]
assert [point.extra_payload.get("label") for point in first_paste] == ["A", "B"]
assert window.document.lines[0].blocks[0].point_ids == [0, 1]
assert window.paste_copied_leds()
assert len(window.document.points) == 7
assert window.canvas.selected_point_ids() == [5, 6]
assert [(point.x, point.y) for point in window.document.points[5:7]] == [(30.0, 0.0), (35.0, -10.0)]
window.undo_last_change()
assert len(window.document.points) == 5
assert window.canvas.selected_point_ids() == [3, 4]
window.undo_last_change()
assert len(window.document.points) == 3
assert window.canvas.selected_point_ids() == [0, 1]
window.redo_last_change()
assert len(window.document.points) == 5
assert window.canvas.selected_point_ids() == [3, 4]
edit_action.setChecked(False)
window.toggle_edit_mode()
assert not window.actions["copy_leds"].isEnabled()
assert not window.actions["paste_leds"].isEnabled()
window._dirty = False
window.close()
def test_menu_bar_tools_buttons() -> None:
if MainWindow is None or QApplication is None:
return
@ -2583,7 +2684,16 @@ def test_classic_menu_contains_expected_actions() -> None:
expected = {
"Файл": ["Создать...", "Открыть", "Сохранить", "Сохранить как", "Экспортировать в изображение...", "Сгенерировать скрипт", "Параметры"],
"Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"],
"Правка": [
"Отменить",
"Повторить",
"Копировать светодиоды",
"Вставить светодиоды",
"Удалить фиксацию",
"Удалить точки",
"Удалить все",
"Очистить выбор",
],
"Вид": [
"Показать всё",
"Сетка",
@ -2695,6 +2805,8 @@ def test_main_window_actions_use_lucide_icons() -> None:
"brush",
"undo",
"redo",
"copy_leds",
"paste_leds",
"delete",
"delete_points",
"clear_all",
@ -2728,6 +2840,8 @@ def test_main_window_action_shortcuts_are_assigned() -> None:
"new": "Ctrl+N",
"generate_script": "Ctrl+G",
"settings": "Ctrl+,",
"copy_leds": "Ctrl+C",
"paste_leds": "Ctrl+V",
"delete_points": "Ctrl+Del",
"clear_all": "Shift+Del",
"clear_selection": "Esc",
@ -2904,6 +3018,46 @@ def test_shortcut_settings_are_editable_and_layout_independent() -> None:
window.close()
def test_copy_paste_shortcuts_preserve_text_editing() -> None:
if QApplication is None or MainWindow is None or QLineEdit is None or QTest is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
doc = MappingDocument()
doc.points = [LedPoint(0, 0, 0.0, 0.0, 0, 0)]
window.document = doc
window.canvas.set_document(doc)
edit_action = window.actions["toggle_edit_mode"]
edit_action.setChecked(True)
window.toggle_edit_mode()
window.canvas.select_points([0])
editor = QLineEdit("текст", window)
editor.show()
window.show()
editor.setFocus()
editor.selectAll()
app.processEvents()
window._copied_led_points = []
QTest.keyClick(editor, Qt.Key_C, Qt.ControlModifier)
app.processEvents()
assert not window._copied_led_points
assert app.clipboard().text() == "текст"
window._copied_led_points = [deepcopy(doc.points[0])]
window._refresh_ui_state()
app.clipboard().setText("вставка")
editor.clear()
editor.setFocus()
app.processEvents()
QTest.keyClick(editor, Qt.Key_V, Qt.ControlModifier)
app.processEvents()
assert editor.text() == "вставка"
assert len(window.document.points) == 1
window._dirty = False
window.close()
def test_standard_dialog_buttons_are_localized() -> None:
if (
QApplication is None
@ -2933,6 +3087,8 @@ def test_lucide_icon_names_are_declared() -> None:
"archive-restore",
"arrow-right",
"chevron-right",
"clipboard-paste",
"copy",
"eraser",
"eye",
"file-plus",

View File

@ -18,6 +18,8 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("settings", "Параметры", "Ctrl+,"),
("undo", "Отменить", "Ctrl+Z"),
("redo", "Повторить", "Ctrl+Y"),
("copy_leds", "Копировать светодиоды", "Ctrl+C"),
("paste_leds", "Вставить светодиоды", "Ctrl+V"),
("delete", "Удалить фиксацию", "Del"),
("delete_points", "Удалить точки", "Ctrl+Del"),
("clear_all", "Удалить все", "Shift+Del"),