From 3699586cc2c6bc3f0ae5c69622b6a39235986419 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Wed, 3 Jun 2026 15:46:05 +0300 Subject: [PATCH] feat: Allow editing and deleting LED points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено полное удаление выбранных LED-точек через контекстное меню холста, таблицы и горячую клавишу Ctrl+Del. - Разрешено редактирование координат X и Y для всех точек схемы, а не только для созданных вручную. - После удаления точек пересчитываются UID, связи блоков и индексы линий без повреждения маршрутов. - UID в таблице теперь показывает исходный id точки, сохраняя внутренний идентификатор для выбора и операций. - Добавлена настройка распределения высоты внутренних областей боковой панели. - Расширены smoke-тесты на удаление точек, редактирование координат, пересчет блоков и состояние интерфейса. --- canvas.py | 5 ++ main_window.py | 172 ++++++++++++++++++++++++++++++++----- models.py | 111 +++++++++++++++++++----- qa_smoke.py | 227 ++++++++++++++++++++++++++++++++++++++++++++----- settings.py | 12 +++ 5 files changed, 464 insertions(+), 63 deletions(-) diff --git a/canvas.py b/canvas.py index 8af2409..7028cdd 100644 --- a/canvas.py +++ b/canvas.py @@ -55,6 +55,7 @@ class LedItem(QGraphicsEllipseItem): class LedCanvas(QGraphicsView): selectionChanged = Signal(list) deleteSelectionRequested = Signal() + deletePointsRequested = Signal() autoBlockFromPointRequested = Signal(int) brushStrokeStarted = Signal(bool) brushPointTouched = Signal(int) @@ -857,6 +858,8 @@ class LedCanvas(QGraphicsView): menu = QMenu(self) delete_action = menu.addAction("Удалить фиксацию выбранных LED") delete_action.setEnabled(self._selection_has_assignment()) + delete_points_action = menu.addAction("Удалить выбранные точки") + delete_points_action.setEnabled(bool(self.selected_point_ids())) clear_selection_action = menu.addAction("Очистить выбор") clear_selection_action.setEnabled(bool(self.selected_point_ids())) auto_block_action = menu.addAction("Автоматически добавить блок") @@ -866,6 +869,8 @@ class LedCanvas(QGraphicsView): chosen = menu.exec(event.globalPos()) if chosen == delete_action: self.deleteSelectionRequested.emit() + elif chosen == delete_points_action: + self.deletePointsRequested.emit() elif chosen == clear_selection_action: self.select_points([]) elif chosen == auto_block_action and point_id is not None: diff --git a/main_window.py b/main_window.py index 8b05361..1103686 100644 --- a/main_window.py +++ b/main_window.py @@ -157,6 +157,7 @@ class MainWindow(QMainWindow): 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 @@ -168,6 +169,7 @@ class MainWindow(QMainWindow): 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.autoBlockFromPointRequested.connect(self.auto_block_from_point) self.canvas.addLedRequested.connect(self.add_manual_led) self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) @@ -282,6 +284,14 @@ class MainWindow(QMainWindow): 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("Ctrl+Shift+Del")) add_action("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc")) add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0")) @@ -350,6 +360,7 @@ class MainWindow(QMainWindow): 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"]) @@ -383,13 +394,18 @@ class MainWindow(QMainWindow): side.setMinimumWidth(0) side.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) side_layout = QVBoxLayout(side) - side_layout.addWidget(self.summary_label) - side_layout.addWidget(self.active_label) + 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) - side_layout.addWidget(structure_label) - side_layout.addWidget(self.tree, 2) + top_layout.addWidget(structure_label) + top_layout.addWidget(self.tree, 1) buttons = QWidget() button_layout = QHBoxLayout(buttons) @@ -411,10 +427,27 @@ class MainWindow(QMainWindow): button.clicked.connect(handler) button_layout.addWidget(button) self.side_buttons[key] = button - side_layout.addWidget(buttons) - side_layout.addWidget(self.selection_label) - side_layout.addWidget(self.table, 3) - side_layout.addWidget(self.status) + 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 @@ -450,6 +483,15 @@ class MainWindow(QMainWindow): 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 @@ -952,7 +994,7 @@ class MainWindow(QMainWindow): self.canvas.select_points([point.uid]) self.refresh_tree() self._on_selection_changed([point.uid]) - self.status.setText(f"Добавлен свободный LED UID {point.uid}: X={point.x:.3f}, Y={point.y:.3f}.") + self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.") self._refresh_ui_state() def add_block_from_selection(self) -> None: @@ -1091,6 +1133,36 @@ class MainWindow(QMainWindow): 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: @@ -1179,6 +1251,26 @@ class MainWindow(QMainWindow): 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, @@ -1362,7 +1454,10 @@ class MainWindow(QMainWindow): self.active_line_id = line_id self.canvas.select_points(pending_ids) self._sync_brush_preview_exclusions() - self._after_mapping_change(f"Кисть: в {block.name} добавлено LED: {added}.", selection_already_updated=True) + 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") @@ -1486,12 +1581,17 @@ class MainWindow(QMainWindow): 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([]) @@ -1516,14 +1616,14 @@ class MainWindow(QMainWindow): point.col, "-" if point.line_id is None else point.line_id, "-" if point.block_id is None else point.block_id, - point.uid, + 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} and point.manual: - item.setToolTip("Можно редактировать координату вручную созданного LED.") + if col in {X_COLUMN, Y_COLUMN}: + item.setToolTip("Можно редактировать координату LED.") else: item.setFlags(item.flags() & ~Qt.ItemIsEditable) self.table.setItem(row, col, item) @@ -1547,9 +1647,6 @@ class MainWindow(QMainWindow): if not isinstance(point_id, int) or not (0 <= point_id < len(self.document.points)): return point = self.document.get_point(point_id) - if not point.manual: - self._restore_table_coordinate_item(item, point) - return try: value = float(item.text().replace(",", ".")) except ValueError: @@ -1561,7 +1658,7 @@ class MainWindow(QMainWindow): item.setData(SORT_ROLE, self._table_sort_value(value)) return self._push_undo() - self.document.update_manual_point_position( + 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, @@ -1574,7 +1671,7 @@ class MainWindow(QMainWindow): 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 UID {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 @@ -1769,6 +1866,7 @@ class MainWindow(QMainWindow): "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, @@ -1857,6 +1955,9 @@ class MainWindow(QMainWindow): 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 @@ -1865,6 +1966,17 @@ class MainWindow(QMainWindow): 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) @@ -1877,8 +1989,18 @@ class MainWindow(QMainWindow): 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: - line = self._get_line_or_none(self.active_line_id) + 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 @@ -1889,9 +2011,11 @@ class MainWindow(QMainWindow): self.status.setText(f"Нельзя создать блок: лимит линий {MAX_LINES}.") return None line = self.document.add_line() - block = self.document.add_block(line.id, []) + 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 @@ -2004,6 +2128,14 @@ class MainWindow(QMainWindow): ) 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 diff --git a/models.py b/models.py index a1b7111..322dc2b 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,6 @@ from __future__ import annotations import json import math -import re from collections import deque from dataclasses import dataclass, field from pathlib import Path @@ -364,25 +363,54 @@ class MappingDocument: self.points.append(point) return point - def update_manual_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None: + def update_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None: point = self.get_point(point_id) - if not point.manual: - raise ValueError("Only manually created LED points can be moved.") if x is not None: point.x = float(x) if y is not None: point.y = float(y) point.row, point.col = self._grid_cell_for_coordinate(point.x, point.y, exclude_uid=point.uid) - def add_block(self, line_id: int, point_ids: list[int]) -> LedBlock: + def update_manual_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None: + self.update_point_position(point_id, x=x, y=y) + + def remove_points(self, point_ids: list[int]) -> int: + point_id_set = {point_id for point_id in point_ids if 0 <= point_id < len(self.points)} + if not point_id_set: + return 0 + old_to_new: dict[int, int] = {} + kept_points: list[LedPoint] = [] + for point in self.points: + if point.uid in point_id_set: + continue + old_to_new[point.uid] = len(kept_points) + point.uid = len(kept_points) + kept_points.append(point) + self.points = kept_points + for line in self.lines: + for block in line.blocks: + block.point_ids = [ + old_to_new[point_id] + for point_id in block.point_ids + if point_id in old_to_new + ] + self.reindex_all() + return len(point_id_set) + + def add_block(self, line_id: int, point_ids: list[int], *, after_block_id: int | None = None) -> LedBlock: line = self.get_line(line_id) clean_point_ids = unique_in_order([ point_id for point_id in point_ids if 0 <= point_id < len(self.points) ]) self._detach_points_from_blocks(clean_point_ids) - block = LedBlock(self._next_block_id, self._next_block_name(line), clean_point_ids) - self._next_block_id += 1 - line.blocks.append(block) + block = LedBlock(len(line.blocks) + 1, f"Блок {len(line.blocks) + 1}", clean_point_ids) + insert_index = len(line.blocks) + if after_block_id is not None: + for index, existing_block in enumerate(line.blocks): + if existing_block.id == after_block_id: + insert_index = index + 1 + break + line.blocks.insert(insert_index, block) for point_id in block.point_ids: point = self.points[point_id] point.line_id = line.id @@ -544,9 +572,15 @@ class MappingDocument: return 0, 0 scale = coordinate_display_scale(anchors) step = MIN_NEIGHBOR_DISTANCE_MM / scale - anchor = min(anchors, key=lambda point: (point.y, point.x, point.uid)) - row = anchor.row + round((y - anchor.y) / step) - col = anchor.col + round((x - anchor.x) / step) + row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors) + source_row_value = x if row_axis == "x" else y + source_col_value = x if col_axis == "x" else y + row_anchor = min(anchors, key=lambda point: (abs((point.x if row_axis == "x" else point.y) - source_row_value), point.uid)) + col_anchor = min(anchors, key=lambda point: (abs((point.x if col_axis == "x" else point.y) - source_col_value), point.uid)) + row_value = row_anchor.x if row_axis == "x" else row_anchor.y + col_value = col_anchor.x if col_axis == "x" else col_anchor.y + row = row_anchor.row + round((source_row_value - row_value) / step * row_sign) + col = col_anchor.col + round((source_col_value - col_value) / step * col_sign) return int(row), int(col) def order_lines_clockwise(self) -> None: @@ -658,9 +692,8 @@ class MappingDocument: start_id = start_ids[0] if start_ids else point_ids[0] groups = self.route_groups_from_points(point_ids, start_id) line.blocks.clear() - for group in groups: - line.blocks.append(LedBlock(self._next_block_id, self._next_block_name(line), group)) - self._next_block_id += 1 + for block_number, group in enumerate(groups, start=1): + line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group)) self.reindex_all() return len(groups) @@ -671,8 +704,8 @@ class MappingDocument: self._detach_points_from_blocks([point_id for group in groups for point_id in group]) line = self.get_line(line_id) for group_ids in groups: - block = LedBlock(self._next_block_id, self._next_block_name(line), unique_in_order(group_ids)) - self._next_block_id += 1 + block_number = len(line.blocks) + 1 + block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids)) line.blocks.append(block) self.order_blocks_clockwise(line_id, reorder_leds=False) self.reindex_all() @@ -748,6 +781,7 @@ class MappingDocument: def reindex_line(self, line_id: int) -> None: line = self.get_line(line_id) + self.renumber_blocks(line) line_index = 0 for block in line.blocks: for block_index, point_id in enumerate(block.point_ids): @@ -759,13 +793,10 @@ class MappingDocument: line_index += 1 @staticmethod - def _next_block_name(line: LedLine) -> str: - max_number = 0 - for block in line.blocks: - match = re.search(r"(\d+)$", block.name) - if match is not None: - max_number = max(max_number, int(match.group(1))) - return f"Блок {max_number + 1}" + def renumber_blocks(line: LedLine) -> None: + for block_number, block in enumerate(line.blocks, start=1): + block.id = block_number + block.name = f"Блок {block_number}" def sorted_unique(values: list[int]) -> list[int]: @@ -816,6 +847,40 @@ def _optional_float(value: Any) -> float | None: return None +def infer_grid_coordinate_axes(points: list[LedPoint]) -> tuple[str, int, str, int]: + row_x = _axis_correlation(points, "row", "x") + row_y = _axis_correlation(points, "row", "y") + col_x = _axis_correlation(points, "col", "x") + col_y = _axis_correlation(points, "col", "y") + standard_score = abs(row_y) + abs(col_x) + transposed_score = abs(row_x) + abs(col_y) + if transposed_score > standard_score: + return "x", _correlation_sign(row_x), "y", _correlation_sign(col_y) + return "y", _correlation_sign(row_y), "x", _correlation_sign(col_x) + + +def _axis_correlation(points: list[LedPoint], grid_attr: str, coordinate_attr: str) -> float: + if len(points) < 2: + return 0.0 + grid_values = [float(getattr(point, grid_attr)) for point in points] + coordinate_values = [float(getattr(point, coordinate_attr)) for point in points] + grid_mean = sum(grid_values) / len(grid_values) + coordinate_mean = sum(coordinate_values) / len(coordinate_values) + covariance = sum( + (grid_value - grid_mean) * (coordinate_value - coordinate_mean) + for grid_value, coordinate_value in zip(grid_values, coordinate_values) + ) + grid_variance = sum((grid_value - grid_mean) ** 2 for grid_value in grid_values) + coordinate_variance = sum((coordinate_value - coordinate_mean) ** 2 for coordinate_value in coordinate_values) + if grid_variance <= 0.0 or coordinate_variance <= 0.0: + return 0.0 + return covariance / math.sqrt(grid_variance * coordinate_variance) + + +def _correlation_sign(value: float) -> int: + return -1 if value < 0.0 else 1 + + def default_color(line_id: int) -> str: colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"] return colors[(line_id - 1) % len(colors)] diff --git a/qa_smoke.py b/qa_smoke.py index abbdc12..ee0d4bf 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -30,7 +30,7 @@ try: from app.background_dialog import BackgroundDialog from app.icons import LUCIDE_PATHS from app.localization import localize_dialog_buttons, localize_message_box - from app.main_window import APP_DIR, MainWindow, ROOT_DIR, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH + from app.main_window import APP_DIR, MainWindow, ROOT_DIR, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH, UID_COLUMN from app.settings import DEFAULT_SHORTCUTS, AppSettings from app.settings_dialog import SettingsDialog from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet @@ -55,6 +55,7 @@ except ImportError: light_stylesheet = None SIDEBAR_COLLAPSED_WIDTH = 34 SIDEBAR_MIN_WIDTH = 180 + UID_COLUMN = 8 QPoint = None QPointF = None QRectF = None @@ -90,13 +91,16 @@ def main() -> None: test_points_without_grid_fields_load, test_manual_led_save_load_roundtrip, test_manual_led_uses_grid_coordinates, + test_manual_led_uses_transposed_source_grid_coordinates, + test_remove_points_reindexes_uids_and_blocks, test_loaded_duplicate_memberships_are_deduplicated, test_empty_blocks_are_preserved, - test_block_names_keep_increasing_in_line, - test_block_names_do_not_renumber_when_line_is_removed, + test_block_numbers_renumber_after_block_changes, + test_block_numbers_are_local_to_each_line, test_reindex_preserves_block_point_order, test_add_block_preserves_start_point, test_append_points_to_block_extends_existing_block, + test_add_block_can_insert_after_selected_block_and_renumber, test_merge_blocks_preserves_first_block_and_reindexes, test_clockwise_block_from_seed_starts_at_selected_point, test_clockwise_block_from_seed_reverses_counterclockwise_corner, @@ -147,10 +151,13 @@ def main() -> None: test_standard_dialog_buttons_are_localized, test_lucide_icon_names_are_declared, test_table_uses_single_line_index_column, - test_manual_led_coordinates_are_editable_in_table, + test_table_uid_column_displays_source_id_but_selects_internal_uid, + test_led_coordinates_are_editable_in_table, + test_main_window_can_delete_selected_points, test_table_selection_uses_selected_rows, test_tree_can_select_and_merge_multiple_blocks, test_sidebar_collapses_and_expands, + test_sidebar_inner_splitter_persists_sizes, test_sidebar_action_toggle_updates_state_and_constraints, test_sidebar_collapsed_tab_click_and_drag_expand, test_canvas_select_points_skips_duplicate_selection_emit, @@ -161,6 +168,7 @@ def main() -> None: test_canvas_zoom_anchor_is_same_with_brush_enabled, test_canvas_brush_preview_highlights_only_actionable_points, test_brush_can_create_new_block_without_preselected_block, + test_brush_creates_new_block_after_selected_tree_block, test_brush_default_stroke_creates_new_block_even_with_selected_block, test_brush_shift_stroke_continues_selected_block, test_main_window_restores_table_state_after_selection_error, @@ -507,6 +515,40 @@ def test_manual_led_uses_grid_coordinates() -> None: assert (manual.row, manual.col) == (2, 1) +def test_manual_led_uses_transposed_source_grid_coordinates() -> None: + points = [ + LedPoint(0, 522, 332.777828, 906.447564, 10, 27), + LedPoint(1, 524, 347.222172, 906.447564, 11, 27), + LedPoint(2, 525, 362.557268, 986.575745, 11, 30), + LedPoint(3, 487, 328.943049, 995.582648, 10, 30), + ] + doc = MappingDocument() + doc.points = points + manual = doc.add_manual_point(340.9004318581997, 922.081576799795) + assert (manual.row, manual.col) == (11, 27) + doc.update_manual_point_position(manual.uid, x=340.9004318581997, y=990.0) + assert (manual.row, manual.col) == (11, 30) + + +def test_remove_points_reindexes_uids_and_blocks() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + first = doc.add_block(line.id, [0, 1, 2]) + second = doc.add_block(line.id, [3, 4, 5]) + + removed = doc.remove_points([1, 4]) + + assert removed == 2 + assert [point.uid for point in doc.points] == list(range(len(doc.points))) + assert [point.source_id for point in doc.points[:4]] == [0, 2, 3, 5] + assert first.point_ids == [0, 1] + assert second.point_ids == [2, 3] + assert [doc.points[point_id].block_id for point_id in first.point_ids] == [1, 1] + assert [doc.points[point_id].block_id for point_id in second.point_ids] == [2, 2] + + def test_loaded_duplicate_memberships_are_deduplicated() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "dupes.json" @@ -549,7 +591,7 @@ def test_empty_blocks_are_preserved() -> None: assert all(block.point_ids == [] for block in loaded.lines[0].blocks) -def test_block_names_keep_increasing_in_line() -> None: +def test_block_numbers_renumber_after_block_changes() -> None: with temp_document_path() as path: doc = MappingDocument() doc.load(path) @@ -564,13 +606,18 @@ def test_block_names_keep_increasing_in_line() -> None: assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"] doc.remove_block(line.id, second.id) + assert [(block.id, block.name) for block in doc.get_line(line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")] + assert doc.points[0].block_id is None + assert third.id == 2 + fourth = doc.add_block(line.id, []) - assert fourth.name == "Блок 4" - assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 3", "Блок 4"] + assert fourth.id == 3 + assert fourth.name == "Блок 3" + assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"] -def test_block_names_do_not_renumber_when_line_is_removed() -> None: +def test_block_numbers_are_local_to_each_line() -> None: with temp_document_path() as path: doc = MappingDocument() doc.load(path) @@ -579,14 +626,16 @@ def test_block_names_do_not_renumber_when_line_is_removed() -> None: first = doc.add_block(first_line.id, []) second = doc.add_block(first_line.id, []) third = doc.add_block(first_line.id, []) - doc.add_block(second_line.id, [0]) + other_line_block = doc.add_block(second_line.id, [0]) + assert [block.id for block in doc.get_line(first_line.id).blocks] == [1, 2, 3] + assert other_line_block.id == 1 doc.remove_block(first_line.id, second.id) - assert [block.name for block in doc.get_line(first_line.id).blocks] == ["Блок 1", "Блок 3"] + assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")] doc.remove_line(second_line.id) - assert [block.name for block in doc.get_line(first_line.id).blocks] == ["Блок 1", "Блок 3"] - assert [first.name, third.name] == ["Блок 1", "Блок 3"] + assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")] + assert [first.name, third.name] == ["Блок 1", "Блок 2"] def test_reindex_preserves_block_point_order() -> None: @@ -637,6 +686,30 @@ def test_append_points_to_block_extends_existing_block() -> None: assert [doc.points[point_id].line_index for point_id in first_block.point_ids] == [0, 1, 2, 3] +def test_add_block_can_insert_after_selected_block_and_renumber() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + first = doc.add_block(line.id, [0, 1]) + second = doc.add_block(line.id, [4, 5]) + third = doc.add_block(line.id, [8, 9]) + + inserted = doc.add_block(line.id, [2, 3], after_block_id=first.id) + + assert [(block.id, block.name, block.point_ids) for block in line.blocks] == [ + (1, "Блок 1", [0, 1]), + (2, "Блок 2", [2, 3]), + (3, "Блок 3", [4, 5]), + (4, "Блок 4", [8, 9]), + ] + assert inserted.id == 2 + assert second.id == 3 + assert third.id == 4 + assert [doc.points[point_id].block_id for point_id in [0, 2, 4, 8]] == [1, 2, 3, 4] + assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4, 5, 8, 9]] == list(range(8)) + + def test_merge_blocks_preserves_first_block_and_reindexes() -> None: with temp_document_path() as path: doc = MappingDocument() @@ -1547,7 +1620,7 @@ def test_classic_menu_contains_expected_actions() -> None: expected = { "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"], - "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"], + "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"], "Вид": [ "Показать всё", "Сетка 34 мм", @@ -1658,6 +1731,7 @@ def test_main_window_actions_use_lucide_icons() -> None: "undo", "redo", "delete", + "delete_points", "clear_all", "clear_selection", "fit_view", @@ -1686,6 +1760,7 @@ def test_main_window_action_shortcuts_are_assigned() -> None: expected = { "generate_script": "Ctrl+G", "settings": "Ctrl+,", + "delete_points": "Ctrl+Del", "clear_all": "Ctrl+Shift+Del", "clear_selection": "Esc", "fit_view": "Ctrl+0", @@ -1939,7 +2014,7 @@ def test_table_uses_single_line_index_column() -> None: window.close() -def test_manual_led_coordinates_are_editable_in_table() -> None: +def test_table_uid_column_displays_source_id_but_selects_internal_uid() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) @@ -1951,7 +2026,31 @@ def test_manual_led_coordinates_are_editable_in_table() -> None: window.document = doc window.canvas.set_document(doc) - window.canvas.select_points([manual.uid]) + window._on_selection_changed([manual.uid]) + app.processEvents() + uid_item = window.table.item(0, UID_COLUMN) + assert uid_item.text() == str(manual.source_id) + assert uid_item.data(Qt.UserRole) == manual.uid + + window.table.clearSelection() + window.table.selectRow(0) + app.processEvents() + assert window.canvas.selected_point_ids() == [manual.uid] + window.close() + + +def test_led_coordinates_are_editable_in_table() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + window.document = doc + window.canvas.set_document(doc) + + window.canvas.select_points([0]) app.processEvents() x_item = window.table.item(0, 1) y_item = window.table.item(0, 2) @@ -1960,14 +2059,39 @@ def test_manual_led_coordinates_are_editable_in_table() -> None: x_item.setText("15.5") app.processEvents() - assert window.document.points[manual.uid].x == 15.5 - assert window.document.points[manual.uid].y == 20.0 + assert window.document.points[0].x == 15.5 assert window._dirty - assert window.canvas.items_by_id[manual.uid].pos() == window.canvas._to_scene_point(15.5, 20.0) + assert window.canvas.items_by_id[0].pos() == window.canvas._to_scene_point(15.5, window.document.points[0].y) + window._dirty = False + window.close() - window.canvas.select_points([0]) + +def test_main_window_can_delete_selected_points() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, [0, 1, 2]) + window.document = doc + window.active_line_id = line.id + window.canvas.set_document(doc) + window.refresh_tree() + + window._confirm_delete_points = lambda _count: True + window.canvas.select_points([1]) + window.delete_selected_points() app.processEvents() - assert not (window.table.item(0, 1).flags() & Qt.ItemIsEditable) + + assert len(window.document.points) == len(fixture_points()) - 1 + assert [point.uid for point in window.document.points] == list(range(len(window.document.points))) + assert window.document.get_line(line.id).blocks[0].point_ids == [0, 1] + assert window.canvas.selected_point_ids() == [] + assert 1 in window.canvas.items_by_id + assert window._dirty window._dirty = False window.close() @@ -2078,6 +2202,26 @@ def test_sidebar_collapses_and_expands() -> None: window.close() +def test_sidebar_inner_splitter_persists_sizes() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + window.settings.save = lambda: None + assert window.sidebar_content_splitter is not None + window.sidebar_content_splitter.setSizes([220, 480]) + window._on_sidebar_content_splitter_moved(220, 1) + app.processEvents() + + sizes = window.settings.sidebar_inner_sizes + assert sizes is not None + assert len(sizes) == 2 + assert all(size > 0 for size in sizes) + ratio = sizes[0] / sum(sizes) + assert 0.25 <= ratio <= 0.40 + window.close() + + def test_sidebar_action_toggle_updates_state_and_constraints() -> None: if MainWindow is None or QApplication is None: return @@ -2364,6 +2508,49 @@ def test_brush_can_create_new_block_without_preselected_block() -> None: window.close() +def test_brush_creates_new_block_after_selected_tree_block() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, [0, 1]) + doc.add_block(line.id, [4, 5]) + window.document = doc + window.active_line_id = line.id + window.canvas.set_document(doc) + window.refresh_tree() + + line_item = window.tree.topLevelItem(0) + first_block_item = line_item.child(0) + window.tree.setCurrentItem(first_block_item) + first_block_item.setSelected(True) + window.actions["brush"].setChecked(True) + window.toggle_brush_mode() + window.start_brush_stroke(False) + window.add_brush_point_to_block(2) + window.add_brush_point_to_block(3) + window.finish_brush_stroke() + app.processEvents() + + line_after = window.document.get_line(line.id) + assert [(block.id, block.point_ids) for block in line_after.blocks] == [ + (1, [0, 1]), + (2, [2, 3]), + (3, [4, 5]), + ] + assert [window.document.points[point_id].block_id for point_id in [0, 2, 4]] == [1, 2, 3] + current = window.tree.currentItem() + assert current is not None + assert current.data(0, Qt.UserRole) == ("block", line.id, 2) + assert window.canvas.selected_point_ids() == [2, 3] + window._dirty = False + window.close() + + def test_brush_default_stroke_creates_new_block_even_with_selected_block() -> None: if MainWindow is None or QApplication is None: return diff --git a/settings.py b/settings.py index 07b27cd..6c662dd 100644 --- a/settings.py +++ b/settings.py @@ -17,6 +17,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [ ("undo", "Отменить", "Ctrl+Z"), ("redo", "Повторить", "Ctrl+Y"), ("delete", "Удалить фиксацию", "Del"), + ("delete_points", "Удалить точки", "Ctrl+Del"), ("clear_all", "Удалить все", "Ctrl+Shift+Del"), ("clear_selection", "Очистить выбор", "Esc"), ("fit_view", "Показать всё", "Ctrl+0"), @@ -54,6 +55,7 @@ class AppSettings: brush_radius: int = 18 default_output_dir: str = "" confirm_partial_generation: bool = True + sidebar_inner_sizes: list[int] | None = None shortcuts: dict[str, str] | None = None @classmethod @@ -83,6 +85,7 @@ class AppSettings: settings.show_led_ids_by_default = bool(settings.show_led_ids_by_default) settings.confirm_partial_generation = bool(settings.confirm_partial_generation) settings.default_output_dir = str(settings.default_output_dir or "") + settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420]) settings.shortcuts = normalize_shortcuts( settings.shortcuts, fill_new_defaults=saved_version < SETTINGS_VERSION, @@ -115,6 +118,15 @@ def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa: return max(minimum, min(maximum, number)) +def normalize_splitter_sizes(value, fallback: list[int]) -> list[int]: # noqa: ANN001 + if not isinstance(value, list) or len(value) != len(fallback): + return fallback[:] + sizes = [clamp_int(size, 80, 10000, fallback[index]) for index, size in enumerate(value)] + if sum(sizes) <= 0: + return fallback[:] + return sizes + + def normalize_shortcuts(value, *, fill_new_defaults: bool = False) -> dict[str, str]: # noqa: ANN001 source = value if isinstance(value, dict) else {} shortcuts: dict[str, str] = {}