feat: Allow editing and deleting LED points

- Добавлено полное удаление выбранных LED-точек через контекстное меню холста, таблицы и горячую клавишу Ctrl+Del.

- Разрешено редактирование координат X и Y для всех точек схемы, а не только для созданных вручную.

- После удаления точек пересчитываются UID, связи блоков и индексы линий без повреждения маршрутов.

- UID в таблице теперь показывает исходный id точки, сохраняя внутренний идентификатор для выбора и операций.

- Добавлена настройка распределения высоты внутренних областей боковой панели.

- Расширены smoke-тесты на удаление точек, редактирование координат, пересчет блоков и состояние интерфейса.
This commit is contained in:
Данил Ярош 2026-06-03 15:46:05 +03:00
parent ffa7be203e
commit 3699586cc2
5 changed files with 464 additions and 63 deletions

View File

@ -55,6 +55,7 @@ class LedItem(QGraphicsEllipseItem):
class LedCanvas(QGraphicsView): class LedCanvas(QGraphicsView):
selectionChanged = Signal(list) selectionChanged = Signal(list)
deleteSelectionRequested = Signal() deleteSelectionRequested = Signal()
deletePointsRequested = Signal()
autoBlockFromPointRequested = Signal(int) autoBlockFromPointRequested = Signal(int)
brushStrokeStarted = Signal(bool) brushStrokeStarted = Signal(bool)
brushPointTouched = Signal(int) brushPointTouched = Signal(int)
@ -857,6 +858,8 @@ class LedCanvas(QGraphicsView):
menu = QMenu(self) menu = QMenu(self)
delete_action = menu.addAction("Удалить фиксацию выбранных LED") delete_action = menu.addAction("Удалить фиксацию выбранных LED")
delete_action.setEnabled(self._selection_has_assignment()) 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 = menu.addAction("Очистить выбор")
clear_selection_action.setEnabled(bool(self.selected_point_ids())) clear_selection_action.setEnabled(bool(self.selected_point_ids()))
auto_block_action = menu.addAction("Автоматически добавить блок") auto_block_action = menu.addAction("Автоматически добавить блок")
@ -866,6 +869,8 @@ class LedCanvas(QGraphicsView):
chosen = menu.exec(event.globalPos()) chosen = menu.exec(event.globalPos())
if chosen == delete_action: if chosen == delete_action:
self.deleteSelectionRequested.emit() self.deleteSelectionRequested.emit()
elif chosen == delete_points_action:
self.deletePointsRequested.emit()
elif chosen == clear_selection_action: elif chosen == clear_selection_action:
self.select_points([]) self.select_points([])
elif chosen == auto_block_action and point_id is not None: elif chosen == auto_block_action and point_id is not None:

View File

@ -157,6 +157,7 @@ class MainWindow(QMainWindow):
self._brush_pending_point_ids: list[int] = [] self._brush_pending_point_ids: list[int] = []
self._brush_pending_point_set: set[int] = set() self._brush_pending_point_set: set[int] = set()
self.sidebar_splitter: QSplitter | None = None self.sidebar_splitter: QSplitter | None = None
self.sidebar_content_splitter: QSplitter | None = None
self.sidebar_stack: QStackedWidget | None = None self.sidebar_stack: QStackedWidget | None = None
self.side_panel: QWidget | None = None self.side_panel: QWidget | None = None
self.sidebar_tab: QToolButton | None = None self.sidebar_tab: QToolButton | None = None
@ -168,6 +169,7 @@ class MainWindow(QMainWindow):
self._apply_canvas_settings() self._apply_canvas_settings()
self.canvas.selectionChanged.connect(self._on_selection_changed) self.canvas.selectionChanged.connect(self._on_selection_changed)
self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments) 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.autoBlockFromPointRequested.connect(self.auto_block_from_point)
self.canvas.addLedRequested.connect(self.add_manual_led) self.canvas.addLedRequested.connect(self.add_manual_led)
self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) self.canvas.brushStrokeStarted.connect(self.start_brush_stroke)
@ -282,6 +284,14 @@ class MainWindow(QMainWindow):
QKeySequence.Delete, QKeySequence.Delete,
"Удалить выбранные назначения LED, блок или линию", "Удалить выбранные назначения 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_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("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc"))
add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0")) 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.addAction(self.actions["redo"])
edit_menu.addSeparator() edit_menu.addSeparator()
edit_menu.addAction(self.actions["delete"]) 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_all"])
edit_menu.addAction(self.actions["clear_selection"]) edit_menu.addAction(self.actions["clear_selection"])
@ -383,13 +394,18 @@ class MainWindow(QMainWindow):
side.setMinimumWidth(0) side.setMinimumWidth(0)
side.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) side.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
side_layout = QVBoxLayout(side) side_layout = QVBoxLayout(side)
side_layout.addWidget(self.summary_label) side_layout.setContentsMargins(4, 4, 4, 4)
side_layout.addWidget(self.active_label)
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 = QLabel("Структура")
structure_label.setMinimumWidth(0) structure_label.setMinimumWidth(0)
structure_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) structure_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
side_layout.addWidget(structure_label) top_layout.addWidget(structure_label)
side_layout.addWidget(self.tree, 2) top_layout.addWidget(self.tree, 1)
buttons = QWidget() buttons = QWidget()
button_layout = QHBoxLayout(buttons) button_layout = QHBoxLayout(buttons)
@ -411,10 +427,27 @@ class MainWindow(QMainWindow):
button.clicked.connect(handler) button.clicked.connect(handler)
button_layout.addWidget(button) button_layout.addWidget(button)
self.side_buttons[key] = button self.side_buttons[key] = button
side_layout.addWidget(buttons) top_layout.addWidget(buttons)
side_layout.addWidget(self.selection_label)
side_layout.addWidget(self.table, 3) bottom_panel = QWidget()
side_layout.addWidget(self.status) 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) collapsed_tab = SidebarTab(self.expand_sidebar)
self.sidebar_tab_container = collapsed_tab self.sidebar_tab_container = collapsed_tab
@ -450,6 +483,15 @@ class MainWindow(QMainWindow):
splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 1000]) splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 1000])
self.setCentralWidget(splitter) 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: def _on_sidebar_splitter_moved(self, _pos: int, _index: int) -> None:
if self.sidebar_splitter is None: if self.sidebar_splitter is None:
return return
@ -952,7 +994,7 @@ class MainWindow(QMainWindow):
self.canvas.select_points([point.uid]) self.canvas.select_points([point.uid])
self.refresh_tree() self.refresh_tree()
self._on_selection_changed([point.uid]) 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() self._refresh_ui_state()
def add_block_from_selection(self) -> None: def add_block_from_selection(self) -> None:
@ -1091,6 +1133,36 @@ class MainWindow(QMainWindow):
self.canvas.select_points(point_ids) self.canvas.select_points(point_ids)
self._after_mapping_change(f"Удалена фиксация LED: {removed}", selection_already_updated=True) 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: def remove_selected_tree_item(self) -> bool:
item = self.tree.currentItem() item = self.tree.currentItem()
if item is None: if item is None:
@ -1179,6 +1251,26 @@ class MainWindow(QMainWindow):
self.tree.setUpdatesEnabled(True) self.tree.setUpdatesEnabled(True)
self._refresh_block_append_buttons() 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( def _attach_block_widget(
self, self,
item: QTreeWidgetItem, item: QTreeWidgetItem,
@ -1362,7 +1454,10 @@ class MainWindow(QMainWindow):
self.active_line_id = line_id self.active_line_id = line_id
self.canvas.select_points(pending_ids) self.canvas.select_points(pending_ids)
self._sync_brush_preview_exclusions() 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: def _disable_brush(self, message: str) -> None:
action = self.actions.get("brush") action = self.actions.get("brush")
@ -1486,12 +1581,17 @@ class MainWindow(QMainWindow):
menu = QMenu(self) menu = QMenu(self)
delete_action = menu.addAction("Удалить фиксацию выбранных LED") delete_action = menu.addAction("Удалить фиксацию выбранных LED")
delete_action.setEnabled(self._selected_table_has_assignment()) 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 = menu.addAction("Очистить выбор")
clear_selection_action.setEnabled(bool(self._selected_table_point_ids())) clear_selection_action.setEnabled(bool(self._selected_table_point_ids()))
chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) chosen = menu.exec(self.table.viewport().mapToGlobal(pos))
if chosen == delete_action: if chosen == delete_action:
self.canvas.select_points(self._selected_table_point_ids()) self.canvas.select_points(self._selected_table_point_ids())
self.clear_selected_point_assignments() 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: elif chosen == clear_selection_action:
self.canvas.select_points([]) self.canvas.select_points([])
@ -1516,14 +1616,14 @@ class MainWindow(QMainWindow):
point.col, point.col,
"-" if point.line_id is None else point.line_id, "-" if point.line_id is None else point.line_id,
"-" if point.block_id is None else point.block_id, "-" if point.block_id is None else point.block_id,
point.uid, point.source_id,
] ]
for col, value in enumerate(values): for col, value in enumerate(values):
item = SortableTableItem(str(value)) item = SortableTableItem(str(value))
item.setData(Qt.UserRole, point.uid) item.setData(Qt.UserRole, point.uid)
item.setData(SORT_ROLE, self._table_sort_value(value)) item.setData(SORT_ROLE, self._table_sort_value(value))
if col in {X_COLUMN, Y_COLUMN} and point.manual: if col in {X_COLUMN, Y_COLUMN}:
item.setToolTip("Можно редактировать координату вручную созданного LED.") item.setToolTip("Можно редактировать координату LED.")
else: else:
item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row, col, item) 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)): if not isinstance(point_id, int) or not (0 <= point_id < len(self.document.points)):
return return
point = self.document.get_point(point_id) point = self.document.get_point(point_id)
if not point.manual:
self._restore_table_coordinate_item(item, point)
return
try: try:
value = float(item.text().replace(",", ".")) value = float(item.text().replace(",", "."))
except ValueError: except ValueError:
@ -1561,7 +1658,7 @@ class MainWindow(QMainWindow):
item.setData(SORT_ROLE, self._table_sort_value(value)) item.setData(SORT_ROLE, self._table_sort_value(value))
return return
self._push_undo() self._push_undo()
self.document.update_manual_point_position( self.document.update_point_position(
point.uid, point.uid,
x=value if item.column() == X_COLUMN else None, x=value if item.column() == X_COLUMN else None,
y=value if item.column() == Y_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.set_document(self.document, reset_view=False)
self.canvas.select_points([point.uid]) self.canvas.select_points([point.uid])
self._on_selection_changed([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 def _restore_table_coordinate_item(self, item: QTableWidgetItem, point) -> None: # noqa: ANN001
value = point.x if item.column() == X_COLUMN else point.y value = point.x if item.column() == X_COLUMN else point.y
@ -1769,6 +1866,7 @@ class MainWindow(QMainWindow):
"rebuild_route": has_active_line, "rebuild_route": has_active_line,
"brush": has_points, "brush": has_points,
"delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(), "delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(),
"delete_points": has_selection,
"clear_all": has_lines, "clear_all": has_lines,
"clear_selection": has_selection, "clear_selection": has_selection,
"fit_view": has_points or has_visible_background, "fit_view": has_points or has_visible_background,
@ -1857,6 +1955,9 @@ class MainWindow(QMainWindow):
def _current_block_target(self) -> tuple[int, int] | None: def _current_block_target(self) -> tuple[int, int] | None:
if self.brush_target is not None and self._find_block_or_none(*self.brush_target) is not None: 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.brush_target
return self._tree_block_target()
def _tree_block_target(self) -> tuple[int, int] | None:
item = self.tree.currentItem() item = self.tree.currentItem()
if item is None: if item is None:
return None return None
@ -1865,6 +1966,17 @@ class MainWindow(QMainWindow):
return data[1], data[2] return data[1], data[2]
return None 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: def _selected_block_target(self) -> tuple[int, int] | None:
for point_id in self.canvas.selected_point_ids(): for point_id in self.canvas.selected_point_ids():
point = self.document.get_point(point_id) point = self.document.get_point(point_id)
@ -1877,8 +1989,18 @@ class MainWindow(QMainWindow):
def _initial_brush_target(self) -> tuple[int, int] | None: def _initial_brush_target(self) -> tuple[int, int] | None:
return self._selected_block_target() or self._current_block_target() 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: 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: if line is None:
point = self.document.get_point(point_id) 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 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}.") self.status.setText(f"Нельзя создать блок: лимит линий {MAX_LINES}.")
return None return None
line = self.document.add_line() 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.active_line_id = line.id
self.brush_target = (line.id, block.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._sync_brush_preview_exclusions()
self.status.setText(f"Кисть: создан {block.name}, добавляйте LED.") self.status.setText(f"Кисть: создан {block.name}, добавляйте LED.")
return self.brush_target return self.brush_target
@ -2004,6 +2128,14 @@ class MainWindow(QMainWindow):
) )
return answer == QMessageBox.StandardButton.Yes 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: def _confirm_discard_changes(self, message: str | None = None) -> bool:
if not self._dirty: if not self._dirty:
return True return True

111
models.py
View File

@ -2,7 +2,6 @@ from __future__ import annotations
import json import json
import math import math
import re
from collections import deque from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@ -364,25 +363,54 @@ class MappingDocument:
self.points.append(point) self.points.append(point)
return 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) 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: if x is not None:
point.x = float(x) point.x = float(x)
if y is not None: if y is not None:
point.y = float(y) point.y = float(y)
point.row, point.col = self._grid_cell_for_coordinate(point.x, point.y, exclude_uid=point.uid) 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) line = self.get_line(line_id)
clean_point_ids = unique_in_order([ clean_point_ids = unique_in_order([
point_id for point_id in point_ids if 0 <= point_id < len(self.points) point_id for point_id in point_ids if 0 <= point_id < len(self.points)
]) ])
self._detach_points_from_blocks(clean_point_ids) self._detach_points_from_blocks(clean_point_ids)
block = LedBlock(self._next_block_id, self._next_block_name(line), clean_point_ids) block = LedBlock(len(line.blocks) + 1, f"Блок {len(line.blocks) + 1}", clean_point_ids)
self._next_block_id += 1 insert_index = len(line.blocks)
line.blocks.append(block) 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: for point_id in block.point_ids:
point = self.points[point_id] point = self.points[point_id]
point.line_id = line.id point.line_id = line.id
@ -544,9 +572,15 @@ class MappingDocument:
return 0, 0 return 0, 0
scale = coordinate_display_scale(anchors) scale = coordinate_display_scale(anchors)
step = MIN_NEIGHBOR_DISTANCE_MM / scale step = MIN_NEIGHBOR_DISTANCE_MM / scale
anchor = min(anchors, key=lambda point: (point.y, point.x, point.uid)) row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
row = anchor.row + round((y - anchor.y) / step) source_row_value = x if row_axis == "x" else y
col = anchor.col + round((x - anchor.x) / step) 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) return int(row), int(col)
def order_lines_clockwise(self) -> None: def order_lines_clockwise(self) -> None:
@ -658,9 +692,8 @@ class MappingDocument:
start_id = start_ids[0] if start_ids else point_ids[0] start_id = start_ids[0] if start_ids else point_ids[0]
groups = self.route_groups_from_points(point_ids, start_id) groups = self.route_groups_from_points(point_ids, start_id)
line.blocks.clear() line.blocks.clear()
for group in groups: for block_number, group in enumerate(groups, start=1):
line.blocks.append(LedBlock(self._next_block_id, self._next_block_name(line), group)) line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group))
self._next_block_id += 1
self.reindex_all() self.reindex_all()
return len(groups) 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]) self._detach_points_from_blocks([point_id for group in groups for point_id in group])
line = self.get_line(line_id) line = self.get_line(line_id)
for group_ids in groups: for group_ids in groups:
block = LedBlock(self._next_block_id, self._next_block_name(line), unique_in_order(group_ids)) block_number = len(line.blocks) + 1
self._next_block_id += 1 block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids))
line.blocks.append(block) line.blocks.append(block)
self.order_blocks_clockwise(line_id, reorder_leds=False) self.order_blocks_clockwise(line_id, reorder_leds=False)
self.reindex_all() self.reindex_all()
@ -748,6 +781,7 @@ class MappingDocument:
def reindex_line(self, line_id: int) -> None: def reindex_line(self, line_id: int) -> None:
line = self.get_line(line_id) line = self.get_line(line_id)
self.renumber_blocks(line)
line_index = 0 line_index = 0
for block in line.blocks: for block in line.blocks:
for block_index, point_id in enumerate(block.point_ids): for block_index, point_id in enumerate(block.point_ids):
@ -759,13 +793,10 @@ class MappingDocument:
line_index += 1 line_index += 1
@staticmethod @staticmethod
def _next_block_name(line: LedLine) -> str: def renumber_blocks(line: LedLine) -> None:
max_number = 0 for block_number, block in enumerate(line.blocks, start=1):
for block in line.blocks: block.id = block_number
match = re.search(r"(\d+)$", block.name) block.name = f"Блок {block_number}"
if match is not None:
max_number = max(max_number, int(match.group(1)))
return f"Блок {max_number + 1}"
def sorted_unique(values: list[int]) -> list[int]: def sorted_unique(values: list[int]) -> list[int]:
@ -816,6 +847,40 @@ def _optional_float(value: Any) -> float | None:
return 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: def default_color(line_id: int) -> str:
colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"] colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"]
return colors[(line_id - 1) % len(colors)] return colors[(line_id - 1) % len(colors)]

View File

@ -30,7 +30,7 @@ try:
from app.background_dialog import BackgroundDialog from app.background_dialog import BackgroundDialog
from app.icons import LUCIDE_PATHS from app.icons import LUCIDE_PATHS
from app.localization import localize_dialog_buttons, localize_message_box 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 import DEFAULT_SHORTCUTS, AppSettings
from app.settings_dialog import SettingsDialog from app.settings_dialog import SettingsDialog
from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet
@ -55,6 +55,7 @@ except ImportError:
light_stylesheet = None light_stylesheet = None
SIDEBAR_COLLAPSED_WIDTH = 34 SIDEBAR_COLLAPSED_WIDTH = 34
SIDEBAR_MIN_WIDTH = 180 SIDEBAR_MIN_WIDTH = 180
UID_COLUMN = 8
QPoint = None QPoint = None
QPointF = None QPointF = None
QRectF = None QRectF = None
@ -90,13 +91,16 @@ def main() -> None:
test_points_without_grid_fields_load, test_points_without_grid_fields_load,
test_manual_led_save_load_roundtrip, test_manual_led_save_load_roundtrip,
test_manual_led_uses_grid_coordinates, 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_loaded_duplicate_memberships_are_deduplicated,
test_empty_blocks_are_preserved, test_empty_blocks_are_preserved,
test_block_names_keep_increasing_in_line, test_block_numbers_renumber_after_block_changes,
test_block_names_do_not_renumber_when_line_is_removed, test_block_numbers_are_local_to_each_line,
test_reindex_preserves_block_point_order, test_reindex_preserves_block_point_order,
test_add_block_preserves_start_point, test_add_block_preserves_start_point,
test_append_points_to_block_extends_existing_block, 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_merge_blocks_preserves_first_block_and_reindexes,
test_clockwise_block_from_seed_starts_at_selected_point, test_clockwise_block_from_seed_starts_at_selected_point,
test_clockwise_block_from_seed_reverses_counterclockwise_corner, test_clockwise_block_from_seed_reverses_counterclockwise_corner,
@ -147,10 +151,13 @@ def main() -> None:
test_standard_dialog_buttons_are_localized, test_standard_dialog_buttons_are_localized,
test_lucide_icon_names_are_declared, test_lucide_icon_names_are_declared,
test_table_uses_single_line_index_column, 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_table_selection_uses_selected_rows,
test_tree_can_select_and_merge_multiple_blocks, test_tree_can_select_and_merge_multiple_blocks,
test_sidebar_collapses_and_expands, test_sidebar_collapses_and_expands,
test_sidebar_inner_splitter_persists_sizes,
test_sidebar_action_toggle_updates_state_and_constraints, test_sidebar_action_toggle_updates_state_and_constraints,
test_sidebar_collapsed_tab_click_and_drag_expand, test_sidebar_collapsed_tab_click_and_drag_expand,
test_canvas_select_points_skips_duplicate_selection_emit, 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_zoom_anchor_is_same_with_brush_enabled,
test_canvas_brush_preview_highlights_only_actionable_points, test_canvas_brush_preview_highlights_only_actionable_points,
test_brush_can_create_new_block_without_preselected_block, 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_default_stroke_creates_new_block_even_with_selected_block,
test_brush_shift_stroke_continues_selected_block, test_brush_shift_stroke_continues_selected_block,
test_main_window_restores_table_state_after_selection_error, 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) 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: def test_loaded_duplicate_memberships_are_deduplicated() -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "dupes.json" 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) 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: with temp_document_path() as path:
doc = MappingDocument() doc = MappingDocument()
doc.load(path) 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"] assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
doc.remove_block(line.id, second.id) 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, []) fourth = doc.add_block(line.id, [])
assert fourth.name == "Блок 4" assert fourth.id == 3
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 3", "Блок 4"] 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: with temp_document_path() as path:
doc = MappingDocument() doc = MappingDocument()
doc.load(path) 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, []) first = doc.add_block(first_line.id, [])
second = doc.add_block(first_line.id, []) second = doc.add_block(first_line.id, [])
third = 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) 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) doc.remove_line(second_line.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")]
assert [first.name, third.name] == ["Блок 1", "Блок 3"] assert [first.name, third.name] == ["Блок 1", "Блок 2"]
def test_reindex_preserves_block_point_order() -> None: 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] 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: def test_merge_blocks_preserves_first_block_and_reindexes() -> None:
with temp_document_path() as path: with temp_document_path() as path:
doc = MappingDocument() doc = MappingDocument()
@ -1547,7 +1620,7 @@ def test_classic_menu_contains_expected_actions() -> None:
expected = { expected = {
"Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"], "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"],
"Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"], "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"],
"Вид": [ "Вид": [
"Показать всё", "Показать всё",
"Сетка 34 мм", "Сетка 34 мм",
@ -1658,6 +1731,7 @@ def test_main_window_actions_use_lucide_icons() -> None:
"undo", "undo",
"redo", "redo",
"delete", "delete",
"delete_points",
"clear_all", "clear_all",
"clear_selection", "clear_selection",
"fit_view", "fit_view",
@ -1686,6 +1760,7 @@ def test_main_window_action_shortcuts_are_assigned() -> None:
expected = { expected = {
"generate_script": "Ctrl+G", "generate_script": "Ctrl+G",
"settings": "Ctrl+,", "settings": "Ctrl+,",
"delete_points": "Ctrl+Del",
"clear_all": "Ctrl+Shift+Del", "clear_all": "Ctrl+Shift+Del",
"clear_selection": "Esc", "clear_selection": "Esc",
"fit_view": "Ctrl+0", "fit_view": "Ctrl+0",
@ -1939,7 +2014,7 @@ def test_table_uses_single_line_index_column() -> None:
window.close() 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: if MainWindow is None or QApplication is None:
return return
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
@ -1951,7 +2026,31 @@ def test_manual_led_coordinates_are_editable_in_table() -> None:
window.document = doc window.document = doc
window.canvas.set_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() app.processEvents()
x_item = window.table.item(0, 1) x_item = window.table.item(0, 1)
y_item = window.table.item(0, 2) 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") x_item.setText("15.5")
app.processEvents() app.processEvents()
assert window.document.points[manual.uid].x == 15.5 assert window.document.points[0].x == 15.5
assert window.document.points[manual.uid].y == 20.0
assert window._dirty 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() 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._dirty = False
window.close() window.close()
@ -2078,6 +2202,26 @@ def test_sidebar_collapses_and_expands() -> None:
window.close() 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: def test_sidebar_action_toggle_updates_state_and_constraints() -> None:
if MainWindow is None or QApplication is None: if MainWindow is None or QApplication is None:
return return
@ -2364,6 +2508,49 @@ def test_brush_can_create_new_block_without_preselected_block() -> None:
window.close() 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: def test_brush_default_stroke_creates_new_block_even_with_selected_block() -> None:
if MainWindow is None or QApplication is None: if MainWindow is None or QApplication is None:
return return

View File

@ -17,6 +17,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("undo", "Отменить", "Ctrl+Z"), ("undo", "Отменить", "Ctrl+Z"),
("redo", "Повторить", "Ctrl+Y"), ("redo", "Повторить", "Ctrl+Y"),
("delete", "Удалить фиксацию", "Del"), ("delete", "Удалить фиксацию", "Del"),
("delete_points", "Удалить точки", "Ctrl+Del"),
("clear_all", "Удалить все", "Ctrl+Shift+Del"), ("clear_all", "Удалить все", "Ctrl+Shift+Del"),
("clear_selection", "Очистить выбор", "Esc"), ("clear_selection", "Очистить выбор", "Esc"),
("fit_view", "Показать всё", "Ctrl+0"), ("fit_view", "Показать всё", "Ctrl+0"),
@ -54,6 +55,7 @@ class AppSettings:
brush_radius: int = 18 brush_radius: int = 18
default_output_dir: str = "" default_output_dir: str = ""
confirm_partial_generation: bool = True confirm_partial_generation: bool = True
sidebar_inner_sizes: list[int] | None = None
shortcuts: dict[str, str] | None = None shortcuts: dict[str, str] | None = None
@classmethod @classmethod
@ -83,6 +85,7 @@ class AppSettings:
settings.show_led_ids_by_default = bool(settings.show_led_ids_by_default) settings.show_led_ids_by_default = bool(settings.show_led_ids_by_default)
settings.confirm_partial_generation = bool(settings.confirm_partial_generation) settings.confirm_partial_generation = bool(settings.confirm_partial_generation)
settings.default_output_dir = str(settings.default_output_dir or "") 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 = normalize_shortcuts(
settings.shortcuts, settings.shortcuts,
fill_new_defaults=saved_version < SETTINGS_VERSION, 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)) 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 def normalize_shortcuts(value, *, fill_new_defaults: bool = False) -> dict[str, str]: # noqa: ANN001
source = value if isinstance(value, dict) else {} source = value if isinstance(value, dict) else {}
shortcuts: dict[str, str] = {} shortcuts: dict[str, str] = {}