From 0b24cbd83ca0d5babd14635c92207fd91d63cb43 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Wed, 9 Sep 2026 08:30:30 +0300 Subject: [PATCH] feat: add LED copy and paste in edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавить команды копирования и вставки выбранных светодиодов в режиме редактирования - назначить на новые команды настраиваемые сочетания Ctrl+C и Ctrl+V - создавать свободные копии с уникальными идентификаторами и каскадным смещением по шагу сетки - сохранять взаимное расположение и пользовательские поля копируемых точек - объединить вставку группы в одну операцию Undo с восстановлением выделения через Redo - не перехватывать стандартное копирование и вставку при редактировании текста и координат - добавить пункты меню, иконки, документацию и проверки, расширив QA до 164 сценариев --- README.md | 4 ++ icons.py | 2 + main_window.py | 97 ++++++++++++++++++++++++++++-- models.py | 32 ++++++++++ qa_smoke.py | 160 ++++++++++++++++++++++++++++++++++++++++++++++++- settings.py | 2 + 6 files changed, 290 insertions(+), 7 deletions(-) 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"),