diff --git a/README.md b/README.md
index 82aa06d..bdaf070 100644
--- a/README.md
+++ b/README.md
@@ -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` |
diff --git a/icons.py b/icons.py
index 2960910..86393a6 100644
--- a/icons.py
+++ b/icons.py
@@ -10,6 +10,8 @@ LUCIDE_PATHS = {
"arrow-right": '',
"brush": '',
"chevron-right": '',
+ "clipboard-paste": '',
+ "copy": '',
"eraser": '',
"eye": '',
"file-plus": '',
diff --git a/main_window.py b/main_window.py
index 7760a47..1616790 100644
--- a/main_window.py
+++ b/main_window.py
@@ -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,
diff --git a/models.py b/models.py
index ebf2cb1..295ed7f 100644
--- a/models.py
+++ b/models.py
@@ -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:
diff --git a/qa_smoke.py b/qa_smoke.py
index 4b0e2d0..b75e3eb 100644
--- a/qa_smoke.py
+++ b/qa_smoke.py
@@ -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",
diff --git a/settings.py b/settings.py
index 9c3f752..b324d0d 100644
--- a/settings.py
+++ b/settings.py
@@ -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"),