feat: Allow editing and deleting LED points
- Добавлено полное удаление выбранных LED-точек через контекстное меню холста, таблицы и горячую клавишу Ctrl+Del. - Разрешено редактирование координат X и Y для всех точек схемы, а не только для созданных вручную. - После удаления точек пересчитываются UID, связи блоков и индексы линий без повреждения маршрутов. - UID в таблице теперь показывает исходный id точки, сохраняя внутренний идентификатор для выбора и операций. - Добавлена настройка распределения высоты внутренних областей боковой панели. - Расширены smoke-тесты на удаление точек, редактирование координат, пересчет блоков и состояние интерфейса.
This commit is contained in:
parent
ffa7be203e
commit
3699586cc2
@ -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:
|
||||||
|
|||||||
172
main_window.py
172
main_window.py
@ -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
111
models.py
@ -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)]
|
||||||
|
|||||||
227
qa_smoke.py
227
qa_smoke.py
@ -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
|
||||||
|
|||||||
12
settings.py
12
settings.py
@ -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] = {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user