diff --git a/canvas.py b/canvas.py
index 0de835e..b6c379e 100644
--- a/canvas.py
+++ b/canvas.py
@@ -6,6 +6,7 @@ from pathlib import Path
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
from PySide6.QtWidgets import (
+ QApplication,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsLineItem,
@@ -58,6 +59,7 @@ class LedCanvas(QGraphicsView):
deletePointsRequested = Signal()
editPointRequested = Signal(int)
pointMoveFinished = Signal(int, float, float)
+ pointsMoveFinished = Signal(list)
autoBlockFromPointRequested = Signal(int)
counterclockwiseBlockFromPointRequested = Signal(int)
brushStrokeStarted = Signal(bool)
@@ -75,7 +77,7 @@ class LedCanvas(QGraphicsView):
self.setRenderHint(QPainter.SmoothPixmapTransform)
self.setMouseTracking(True)
self.viewport().setMouseTracking(True)
- self.setDragMode(QGraphicsView.RubberBandDrag)
+ self.setDragMode(QGraphicsView.NoDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
self.document: MappingDocument | None = None
@@ -112,14 +114,21 @@ class LedCanvas(QGraphicsView):
self._last_pan_pos = None
self._last_mouse_pos = None
self._point_drag: dict | None = None
+ self._rubber_origin: QPointF | None = None
+ self._rubber_item: QGraphicsRectItem | None = None
+ self._selection_bbox_item: QGraphicsRectItem | None = None
self._brush_enabled = False
self._add_led_enabled = False
+ self._edit_tool = "select"
+ self._is_drawing_leds = False
+ self._last_drawn_pos = None
self._is_brush_stroke = False
self._brush_touched_ids: set[int] = set()
self._brush_preview_ids: set[int] = set()
self._brush_excluded_point_ids: set[int] = set()
self._last_brush_viewport_pos = None
self._last_selected_ids: set[int] = set()
+ self._edit_mode_enabled = False
self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
self.scene().selectionChanged.connect(self._emit_selection)
self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.")
@@ -128,6 +137,29 @@ class LedCanvas(QGraphicsView):
self.document = document
self.rebuild(reset_view=reset_view)
+ def set_edit_tool(self, tool: str) -> None:
+ if tool not in ("select", "move", "draw"):
+ return
+ self._clear_rubber_band()
+ self._edit_tool = tool
+ if tool == "select":
+ self.setDragMode(QGraphicsView.NoDrag)
+ self._add_led_enabled = False
+ elif tool == "move":
+ self.setDragMode(QGraphicsView.NoDrag)
+ self._add_led_enabled = False
+ elif tool == "draw":
+ self.setDragMode(QGraphicsView.NoDrag)
+ self._add_led_enabled = True
+ self._apply_default_cursor()
+
+ def set_edit_mode_enabled(self, enabled: bool) -> None:
+ if self._edit_mode_enabled == enabled:
+ return
+ self._edit_mode_enabled = enabled
+ self._update_scene_rect()
+ self.rebuild(reset_view=False)
+
def set_active_line(self, line_id: int | None) -> None:
if self.active_line_id == line_id:
return
@@ -196,7 +228,7 @@ class LedCanvas(QGraphicsView):
self._add_led_enabled = False
self._is_brush_stroke = False
self._clear_brush_state()
- self.setDragMode(QGraphicsView.NoDrag if enabled else QGraphicsView.RubberBandDrag)
+ self.setDragMode(QGraphicsView.NoDrag)
if enabled:
self._ensure_brush_zone()
else:
@@ -275,6 +307,20 @@ class LedCanvas(QGraphicsView):
def rebuild(self, *, reset_view: bool = True) -> None:
old_transform = self.transform()
+ old_h_val = self.horizontalScrollBar().value()
+ old_v_val = self.verticalScrollBar().value()
+ old_stable_bounds = getattr(self, "_stable_edit_bounds", None)
+
+ # Anchor the camera center to a stable point to prevent viewport jump when bounds change
+ ref_uid = None
+ ref_offset = None
+ if self.document is not None and self.document.points:
+ ref_uid = self.document.points[0].uid
+ ref_item = self.items_by_id.get(ref_uid)
+ if ref_item is not None and isValid(ref_item):
+ old_center = self.mapToScene(self.viewport().rect().center())
+ ref_offset = old_center - ref_item.pos()
+
old_center = self.mapToScene(self.viewport().rect().center())
scene = self.scene()
scene.blockSignals(True)
@@ -293,6 +339,10 @@ class LedCanvas(QGraphicsView):
self._brush_touched_ids.clear()
self._last_selected_ids.clear()
scene.clear()
+ # Reset items invalidated by scene.clear()
+ self._rubber_item = None
+ self._rubber_origin = None
+ self._selection_bbox_item = None
finally:
scene.blockSignals(False)
self.setUpdatesEnabled(True)
@@ -303,12 +353,23 @@ class LedCanvas(QGraphicsView):
self._update_scene_rect()
return
- self._display_scale = coordinate_display_scale(self.document.points)
+ # Keep display scale completely stable to prevent grid and camera jumps
+ if reset_view or getattr(self, "_stable_display_scale", None) is None:
+ self._stable_display_scale = coordinate_display_scale(self.document.points)
+
+ self._display_scale = self._stable_display_scale
+
xs = [point.x * self._display_scale for point in self.document.points]
ys = [point.y * self._display_scale for point in self.document.points]
- min_x, max_x = min(xs), max(xs)
- min_y, max_y = min(ys), max(ys)
- self._raw_bounds = (min_x, max_x, min_y, max_y)
+ min_x = min(xs) if xs else 0.0
+ max_x = max(xs) if xs else 100.0
+ min_y = min(ys) if ys else 0.0
+ max_y = max(ys) if ys else 100.0
+
+ if reset_view or getattr(self, "_stable_edit_bounds", None) is None:
+ self._stable_edit_bounds = (min_x, max_x, min_y, max_y)
+
+ self._raw_bounds = self._stable_edit_bounds
self._margin = max(self._point_radius * 4, 40)
for point in self.document.points:
@@ -317,11 +378,12 @@ class LedCanvas(QGraphicsView):
self.items_by_id[point.uid] = item
self.scene().addItem(item)
+ stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
self._content_scene_rect = QRectF(
0,
0,
- max(max_x - min_x, 1.0) + self._margin * 2,
- max(max_y - min_y, 1.0) + self._margin * 2,
+ max(stable_max_x - stable_min_x, 1.0) + self._margin * 2,
+ max(stable_max_y - stable_min_y, 1.0) + self._margin * 2,
)
self._refresh_background()
self._update_scene_rect()
@@ -331,7 +393,19 @@ class LedCanvas(QGraphicsView):
self.fit_to_content()
else:
self.setTransform(old_transform)
- self.centerOn(old_center)
+ # If bounds did not change, restore scrollbars directly to avoid floating point drift
+ if old_stable_bounds is not None and getattr(self, "_stable_edit_bounds", None) == old_stable_bounds:
+ self.horizontalScrollBar().setValue(old_h_val)
+ self.verticalScrollBar().setValue(old_v_val)
+ else:
+ if ref_uid is not None and ref_offset is not None:
+ new_item = self.items_by_id.get(ref_uid)
+ if new_item is not None and isValid(new_item):
+ self.centerOn(new_item.pos() + ref_offset)
+ else:
+ self.centerOn(old_center)
+ else:
+ self.centerOn(old_center)
def refresh_styles(self, redraw_connections: bool = True) -> None:
if self.document is None:
@@ -487,6 +561,9 @@ class LedCanvas(QGraphicsView):
rect = rect.united(background_rect)
if rect is None:
rect = QRectF(0, 0, 1, 1)
+ if self._edit_mode_enabled:
+ # Expand the scene viewport by 8000 units in edit mode so users can draw and pan without bounds
+ rect = rect.adjusted(-8000, -8000, 8000, 8000)
self.scene().setSceneRect(rect)
def _update_background_handles(self) -> None:
@@ -533,7 +610,18 @@ class LedCanvas(QGraphicsView):
self._update_scene_rect()
if self.scene() is None or self.sceneRect().isEmpty():
return
- self.fitInView(self.sceneRect(), Qt.KeepAspectRatio)
+
+ # Focus camera on actual document content bounds, ignoring expanded edit sceneRect bounds
+ rect = self._content_scene_rect
+ background_rect = self._background_scene_rect()
+ if rect is None:
+ rect = background_rect
+ elif background_rect is not None:
+ rect = rect.united(background_rect)
+ if rect is None or rect.isEmpty():
+ rect = self.sceneRect()
+
+ self.fitInView(rect, Qt.KeepAspectRatio)
def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None:
if self.viewport().rect().isEmpty():
@@ -625,15 +713,24 @@ class LedCanvas(QGraphicsView):
self._start_pan(event.pos())
event.accept()
return
- if event.button() == Qt.LeftButton and event.modifiers() & Qt.AltModifier and self._start_point_drag(event.pos()):
+ is_move_tool = getattr(self, "_edit_tool", "select") == "move"
+ has_alt = bool(event.modifiers() & Qt.AltModifier)
+ if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()):
event.accept()
return
if self._background_edit_mode and event.button() == Qt.LeftButton and self._start_background_edit(event.pos()):
event.accept()
return
if self._add_led_enabled and event.button() == Qt.LeftButton:
- point = self._document_point_from_scene(self.mapToScene(event.pos()))
- self.addLedRequested.emit(point.x(), point.y())
+ if getattr(self, "_edit_tool", "select") == "draw":
+ self._is_drawing_leds = True
+ scene_pos = self.mapToScene(event.pos())
+ self._last_drawn_pos = scene_pos
+ point = self._document_point_from_scene(scene_pos)
+ self.addLedRequested.emit(point.x(), point.y())
+ else:
+ point = self._document_point_from_scene(self.mapToScene(event.pos()))
+ self.addLedRequested.emit(point.x(), point.y())
event.accept()
return
if self._brush_enabled and event.button() == Qt.LeftButton:
@@ -644,6 +741,14 @@ class LedCanvas(QGraphicsView):
self._touch_brush_point(event.pos())
event.accept()
return
+ # Custom rubber band selection in select mode
+ if event.button() == Qt.LeftButton and self._edit_tool == "select" and not self._brush_enabled:
+ item = self._led_item_at(event.pos())
+ if item is None:
+ # Click on empty space — start rubber band
+ self._start_rubber_band(self.mapToScene(event.pos()))
+ event.accept()
+ return
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # noqa: ANN001
@@ -672,6 +777,19 @@ class LedCanvas(QGraphicsView):
self._move_point_drag(event.pos())
event.accept()
return
+ if getattr(self, "_is_drawing_leds", False) and event.buttons() & Qt.LeftButton:
+ scene_pos = self.mapToScene(event.pos())
+ if getattr(self, "_last_drawn_pos", None) is not None:
+ dx = scene_pos.x() - self._last_drawn_pos.x()
+ dy = scene_pos.y() - self._last_drawn_pos.y()
+ dist = math.hypot(dx, dy)
+ doc_dist = dist / (self._display_scale or 1.0)
+ if doc_dist >= 34.0:
+ point = self._document_point_from_scene(scene_pos)
+ self.addLedRequested.emit(point.x(), point.y())
+ self._last_drawn_pos = scene_pos
+ event.accept()
+ return
if self._brush_enabled and self._is_brush_stroke:
self._update_brush_zone(event.pos())
self._touch_brush_point(event.pos())
@@ -681,6 +799,11 @@ class LedCanvas(QGraphicsView):
self._update_brush_zone(event.pos())
event.accept()
return
+ # Update custom rubber band
+ if self._rubber_origin is not None and event.buttons() & Qt.LeftButton:
+ self._update_rubber_band(self.mapToScene(event.pos()))
+ event.accept()
+ return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # noqa: ANN001
@@ -688,6 +811,11 @@ class LedCanvas(QGraphicsView):
if event.button() == Qt.RightButton:
event.accept()
return
+ if event.button() == Qt.LeftButton and getattr(self, "_is_drawing_leds", False):
+ self._is_drawing_leds = False
+ self._last_drawn_pos = None
+ event.accept()
+ return
if self._should_finish_pan(event.button()):
self._finish_pan()
event.accept()
@@ -708,22 +836,156 @@ class LedCanvas(QGraphicsView):
self.brushStrokeFinished.emit()
event.accept()
return
+ # Finish custom rubber band selection
+ if event.button() == Qt.LeftButton and self._rubber_origin is not None:
+ self._finish_rubber_band(self.mapToScene(event.pos()), event.modifiers())
+ event.accept()
+ return
super().mouseReleaseEvent(event)
+ # ------------------------------------------------------------------
+ # Custom rubber-band selection (replaces Qt's built-in RubberBandDrag
+ # which cannot be reliably styled in Qt6/PySide6).
+ # ------------------------------------------------------------------
+
+ def _start_rubber_band(self, scene_pos: QPointF) -> None:
+ self._clear_rubber_band()
+ self._rubber_origin = scene_pos # ← сохраняем origin здесь, после clear
+ item = QGraphicsRectItem(QRectF(scene_pos, scene_pos))
+ item.setZValue(1000)
+ item.setPen(QPen(QColor(59, 130, 246, 220), 1.5, Qt.SolidLine))
+ item.setBrush(QBrush(QColor(59, 130, 246, 60)))
+ item.setFlag(QGraphicsRectItem.ItemIgnoresTransformations, False)
+ self.scene().addItem(item)
+ self._rubber_item = item
+
+ def _update_rubber_band(self, scene_pos: QPointF) -> None:
+ if self._rubber_item is None or self._rubber_origin is None:
+ return
+ origin = self._rubber_origin
+ rect = QRectF(
+ min(origin.x(), scene_pos.x()),
+ min(origin.y(), scene_pos.y()),
+ abs(scene_pos.x() - origin.x()),
+ abs(scene_pos.y() - origin.y()),
+ )
+ self._rubber_item.setRect(rect)
+
+ def _finish_rubber_band(self, scene_pos: QPointF, modifiers: Qt.KeyboardModifiers) -> None:
+ if self._rubber_origin is None:
+ self._clear_rubber_band()
+ return
+ origin = self._rubber_origin
+ rect = QRectF(
+ min(origin.x(), scene_pos.x()),
+ min(origin.y(), scene_pos.y()),
+ abs(scene_pos.x() - origin.x()),
+ abs(scene_pos.y() - origin.y()),
+ )
+ self._clear_rubber_band()
+
+ # Collect all LedItems inside the rubber band rectangle
+ items_in_rect = self.scene().items(rect, Qt.IntersectsItemBoundingRect)
+ hit_ids = [item.point_id for item in items_in_rect if isinstance(item, LedItem)]
+
+ keep_shift = bool(modifiers & (Qt.ShiftModifier | Qt.ControlModifier))
+ if keep_shift:
+ current = list(self.selected_point_ids())
+ merged = list(dict.fromkeys(current + hit_ids))
+ self.select_points(merged)
+ else:
+ self.select_points(hit_ids)
+
+
+ def _clear_rubber_band(self) -> None:
+ if self._rubber_item is not None and isValid(self._rubber_item):
+ self.scene().removeItem(self._rubber_item)
+ self._rubber_item = None
+ self._rubber_origin = None # сбрасываем оба — вызывается только при финале или реинициализации
+
+ # ------------------------------------------------------------------
+ # Selection bounding box highlight
+ # ------------------------------------------------------------------
+
+ def _update_selection_bbox(self) -> None:
+ """Draw / update a dashed bounding rectangle around all selected LEDs."""
+ selected_items = [
+ item for item in self.scene().selectedItems()
+ if isinstance(item, LedItem) and isValid(item)
+ ]
+
+ if not selected_items:
+ # Hide the bbox rect when nothing is selected
+ if self._selection_bbox_item is not None and isValid(self._selection_bbox_item):
+ self._selection_bbox_item.setVisible(False)
+ return
+
+ # Compute union bounding rect in scene coords (item pos is centre)
+ xs = [item.pos().x() for item in selected_items]
+ ys = [item.pos().y() for item in selected_items]
+ radius = selected_items[0].rect().width() / 2.0
+ padding = radius + 4.0
+ x_min = min(xs) - padding
+ y_min = min(ys) - padding
+ x_max = max(xs) + padding
+ y_max = max(ys) + padding
+ bbox = QRectF(x_min, y_min, x_max - x_min, y_max - y_min)
+
+ if self._selection_bbox_item is None or not isValid(self._selection_bbox_item):
+ item = QGraphicsRectItem(bbox)
+ item.setZValue(999)
+ pen = QPen(QColor(99, 179, 255, 200), 1.2, Qt.DashLine)
+ pen.setDashPattern([6, 4])
+ item.setPen(pen)
+ item.setBrush(QBrush(Qt.NoBrush))
+ item.setFlag(QGraphicsRectItem.ItemIsSelectable, False)
+ item.setFlag(QGraphicsRectItem.ItemIsFocusable, False)
+ self.scene().addItem(item)
+ self._selection_bbox_item = item
+ else:
+ self._selection_bbox_item.setRect(bbox)
+ self._selection_bbox_item.setVisible(True)
+
def _start_point_drag(self, viewport_pos) -> bool: # noqa: ANN001
if self.document is None:
return False
item = self._led_item_at(viewport_pos)
- if item is None:
+
+ # Check if click is inside the active selection bounding box
+ clicked_inside_bbox = False
+ if self._selection_bbox_item is not None and isValid(self._selection_bbox_item) and self._selection_bbox_item.isVisible():
+ scene_pos = self.mapToScene(viewport_pos)
+ if self._selection_bbox_item.rect().contains(scene_pos):
+ clicked_inside_bbox = True
+
+ if item is None and not clicked_inside_bbox:
return False
+
+ # Check selection and modifiers
+ selected_ids = list(self.selected_point_ids())
+ if item is not None and item.point_id not in selected_ids:
+ modifiers = QApplication.keyboardModifiers()
+ if modifiers & (Qt.ShiftModifier | Qt.ControlModifier):
+ selected_ids.append(item.point_id)
+ else:
+ selected_ids = [item.point_id]
+ self.select_points(selected_ids)
+
scene_pos = self.mapToScene(viewport_pos)
+ drag_items = []
+ for pid in selected_ids:
+ it = self.items_by_id.get(pid)
+ if it is not None and isValid(it):
+ drag_items.append({
+ "point_id": pid,
+ "item": it,
+ "offset": it.pos() - scene_pos,
+ "start_pos": it.pos(),
+ })
+
self._point_drag = {
- "point_id": item.point_id,
- "item": item,
- "offset": item.pos() - scene_pos,
- "start_pos": item.pos(),
+ "items": drag_items
}
- self.select_points([item.point_id])
self.setDragMode(QGraphicsView.NoDrag)
self.viewport().setCursor(Qt.ClosedHandCursor)
return True
@@ -731,29 +993,38 @@ class LedCanvas(QGraphicsView):
def _move_point_drag(self, viewport_pos) -> None: # noqa: ANN001
if self._point_drag is None:
return
- item = self._point_drag.get("item")
- if item is None or not isValid(item):
- return
- offset = self._point_drag.get("offset", QPointF(0.0, 0.0))
- item.setPos(self.mapToScene(viewport_pos) + offset)
+ scene_pos = self.mapToScene(viewport_pos)
+ for d in self._point_drag.get("items", []):
+ item = d.get("item")
+ if item is not None and isValid(item):
+ offset = d.get("offset", QPointF(0.0, 0.0))
+ item.setPos(scene_pos + offset)
self._position_line_index_labels()
+ self._update_selection_bbox()
def _finish_point_drag(self, viewport_pos) -> None: # noqa: ANN001
if self._point_drag is None:
return
self._move_point_drag(viewport_pos)
- point_id = int(self._point_drag.get("point_id", -1))
- item = self._point_drag.get("item")
- start_pos = self._point_drag.get("start_pos")
+ drag_items = self._point_drag.get("items", [])
self._point_drag = None
- self.setDragMode(QGraphicsView.NoDrag if self._brush_enabled else QGraphicsView.RubberBandDrag)
+
+ is_move_tool = getattr(self, "_edit_tool", "select") == "move"
+ self.setDragMode(QGraphicsView.NoDrag)
self._apply_default_cursor()
- if item is None or not isValid(item) or point_id < 0:
- return
- if isinstance(start_pos, QPointF) and (item.pos() - start_pos).manhattanLength() < 0.001:
- return
- document_point = self._document_point_from_scene(item.pos())
- self.pointMoveFinished.emit(point_id, document_point.x(), document_point.y())
+
+ moves = []
+ for d in drag_items:
+ item = d.get("item")
+ point_id = d.get("point_id")
+ start_pos = d.get("start_pos")
+ if item is not None and isValid(item) and isinstance(start_pos, QPointF):
+ if (item.pos() - start_pos).manhattanLength() > 0.001:
+ document_point = self._document_point_from_scene(item.pos())
+ moves.append((point_id, document_point.x(), document_point.y()))
+
+ if moves:
+ self.pointsMoveFinished.emit(moves)
def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001
if self.document is None or self._background_item is None or not isValid(self._background_item):
@@ -930,6 +1201,40 @@ class LedCanvas(QGraphicsView):
auto_block_action.setEnabled(point_id is not None)
counterclockwise_block_action = menu.addAction("Автоматически добавить блок против часовой")
counterclockwise_block_action.setEnabled(point_id is not None)
+
+ select_block_action = None
+ select_line_action = None
+ point = None
+ if point_id is not None and self.document is not None:
+ try:
+ point = self.document.get_point(point_id)
+ except (IndexError, KeyError):
+ pass
+
+ if self._edit_mode_enabled and point is not None:
+ line_id = point.line_id
+ block_id = point.block_id
+
+ if line_id is not None:
+ line_name = f"Линия {line_id}"
+ try:
+ line = self.document.get_line(line_id)
+ line_name = line.name
+ except KeyError:
+ pass
+
+ menu.addSeparator()
+ select_line_action = menu.addAction(f"Выделить линию '{line_name}'")
+
+ if block_id is not None:
+ block_name = f"Блок {block_id}"
+ try:
+ block = self.document._get_block(line_id, block_id)
+ block_name = block.name
+ except KeyError:
+ pass
+ select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
+
fit_action = menu.addAction("Показать все точки")
fit_action.setEnabled(self.document is not None and bool(self.document.points))
chosen = menu.exec(event.globalPos())
@@ -945,6 +1250,12 @@ class LedCanvas(QGraphicsView):
self.autoBlockFromPointRequested.emit(point_id)
elif chosen == counterclockwise_block_action and point_id is not None:
self.counterclockwiseBlockFromPointRequested.emit(point_id)
+ elif select_line_action is not None and chosen == select_line_action and point is not None and point.line_id is not None:
+ line_point_ids = [p.uid for p in self.document.points if p.line_id == point.line_id]
+ self.select_points(line_point_ids)
+ elif select_block_action is not None and chosen == select_block_action and point is not None and point.line_id is not None and point.block_id is not None:
+ block_point_ids = [p.uid for p in self.document.points if p.line_id == point.line_id and p.block_id == point.block_id]
+ self.select_points(block_point_ids)
elif chosen == fit_action:
self.fit_to_content()
@@ -965,6 +1276,7 @@ class LedCanvas(QGraphicsView):
self._refresh_selection_pens(selected_ids ^ self._last_selected_ids)
self._last_selected_ids = selected_ids
self._refresh_line_index_labels()
+ self._update_selection_bbox()
self.selectionChanged.emit(sorted(selected_ids))
def _refresh_selection_pens(self, point_ids: set[int]) -> None:
@@ -1166,7 +1478,7 @@ class LedCanvas(QGraphicsView):
first_row = math.floor((bottom_y - origin_y) / self._grid_step)
last_row = math.ceil((top_y - origin_y) / self._grid_step)
grid_span = (last_col - first_col) + (last_row - first_row)
- step_multiplier = max(1, math.ceil(grid_span / 800))
+ step_multiplier = 1
pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6)
pen.setCosmetic(True)
diff --git a/icons.py b/icons.py
index 1c5f224..2960910 100644
--- a/icons.py
+++ b/icons.py
@@ -30,6 +30,14 @@ LUCIDE_PATHS = {
"settings": '',
"trash-2": '',
"undo-2": '',
+ "pencil": '',
+ "mouse-pointer": '',
+ "move": '',
+ "plus-circle": '',
+ "align-horizontal": '',
+ "align-vertical": '',
+ "grid-horizontal": '',
+ "grid-vertical": '',
}
diff --git a/main_window.py b/main_window.py
index dedecbf..93de41d 100644
--- a/main_window.py
+++ b/main_window.py
@@ -6,6 +6,7 @@ import sys
from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt, QTimer
from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence
from PySide6.QtWidgets import (
+ QButtonGroup,
QAbstractItemView,
QAbstractScrollArea,
QApplication,
@@ -225,6 +226,8 @@ class MainWindow(QMainWindow):
self._sidebar_collapsed = False
self._sidebar_last_width = SIDEBAR_DEFAULT_WIDTH
self.menu_buttons_widget: QWidget | None = None
+ self.right_panel: QFrame | None = None
+ self.right_panel_buttons: dict[str, tuple[QToolButton, str]] = {}
self.canvas = LedCanvas()
self._apply_canvas_settings()
@@ -233,6 +236,7 @@ class MainWindow(QMainWindow):
self.canvas.deletePointsRequested.connect(self.delete_selected_points)
self.canvas.editPointRequested.connect(self.edit_point_coordinates)
self.canvas.pointMoveFinished.connect(self.move_point_from_canvas)
+ self.canvas.pointsMoveFinished.connect(self.move_points_from_canvas)
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
self.canvas.addLedRequested.connect(self.add_manual_led)
@@ -309,6 +313,7 @@ class MainWindow(QMainWindow):
self.tree.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
self._build_actions()
self._build_layout()
+ self._refresh_theme_icons()
application = QApplication.instance()
if application is not None:
application.focusChanged.connect(self._on_application_focus_changed)
@@ -443,6 +448,15 @@ class MainWindow(QMainWindow):
led_ids_action.setCheckable(True)
led_ids_action.setChecked(self.settings.show_led_ids_by_default)
add_action("toggle_sidebar", "Скрыть боковую панель", self.toggle_sidebar, "chevron-right", QKeySequence("F9"))
+ edit_mode_action = add_action(
+ "toggle_edit_mode",
+ "Режим редактирования",
+ self.toggle_edit_mode,
+ "pencil",
+ QKeySequence("Ctrl+E"),
+ "Включить или выключить режим редактирования схемы",
+ )
+ edit_mode_action.setCheckable(True)
self._build_menu_bar()
self.addAction(self.delete_action)
@@ -513,14 +527,13 @@ class MainWindow(QMainWindow):
layout.addWidget(separator)
# Add Tools actions as buttons
- for key in ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush"]:
+ for key in ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush", "toggle_edit_mode"]:
action = self.actions[key]
btn = QToolButton(self.menu_buttons_widget)
btn.setDefaultAction(action)
btn.setIconSize(QSize(16, 16))
btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
btn.setAutoRaise(True)
- btn.setStyleSheet("QToolButton { border: none; padding: 2px; }")
layout.addWidget(btn)
self.menu_buttons_widget.show()
@@ -626,11 +639,22 @@ class MainWindow(QMainWindow):
self.sidebar_stack.addWidget(side)
self.sidebar_stack.addWidget(collapsed_tab)
+ # Build right vertical edit panel
+ self._build_right_edit_panel()
+
+ # Central canvas container with right edit panel
+ canvas_container = QWidget()
+ canvas_layout = QHBoxLayout(canvas_container)
+ canvas_layout.setContentsMargins(0, 0, 0, 0)
+ canvas_layout.setSpacing(0)
+ canvas_layout.addWidget(self.canvas, 1)
+ canvas_layout.addWidget(self.right_panel)
+
splitter = QSplitter(Qt.Horizontal)
self.sidebar_splitter = splitter
splitter.setChildrenCollapsible(False)
splitter.addWidget(self.sidebar_stack)
- splitter.addWidget(self.canvas)
+ splitter.addWidget(canvas_container)
splitter.setCollapsible(0, False)
splitter.setCollapsible(1, False)
splitter.setStretchFactor(0, 0)
@@ -692,15 +716,237 @@ class MainWindow(QMainWindow):
def toggle_add_led_mode(self) -> None:
action = self.actions.get("add_led")
enabled = True if action is None else action.isChecked()
- self.canvas.set_add_led_enabled(enabled)
+
+ # Auto-enable edit mode if add_led is enabled
+ edit_mode_action = self.actions.get("toggle_edit_mode")
if enabled:
+ if edit_mode_action is not None and not edit_mode_action.isChecked():
+ edit_mode_action.setChecked(True)
+ self.toggle_edit_mode()
+ if hasattr(self, "right_panel_buttons") and "draw" in self.right_panel_buttons:
+ self.right_panel_buttons["draw"][0].setChecked(True)
+ self.canvas.set_edit_tool("draw")
+
brush_action = self.actions.get("brush")
if brush_action is not None:
brush_action.setChecked(False)
+ self.canvas.set_brush_enabled(False)
background_edit_action = self.actions.get("background_edit")
if background_edit_action is not None:
background_edit_action.setChecked(False)
- self.status.setText("Инструмент добавления LED: кликните по схеме, чтобы поставить свободный светодиод.")
+ self.canvas.set_background_edit_mode(False)
+ self.status.setText("Инструмент рисования LED: кликните по схеме или проведите мышью, чтобы поставить светодиоды.")
+ else:
+ self.canvas.set_add_led_enabled(False)
+ if edit_mode_action is not None and edit_mode_action.isChecked():
+ if hasattr(self, "right_panel_buttons") and "select" in self.right_panel_buttons:
+ self.right_panel_buttons["select"][0].setChecked(True)
+ self.canvas.set_edit_tool("select")
+ self.status.setText("Рисование светодиодов отключено.")
+
+ def toggle_edit_mode(self) -> None:
+ action = self.actions.get("toggle_edit_mode")
+ enabled = bool(action is not None and action.isChecked())
+
+ self.canvas.set_edit_mode_enabled(enabled)
+
+ if hasattr(self, "right_panel") and self.right_panel is not None:
+ self.right_panel.setVisible(enabled)
+
+ if enabled:
+ if hasattr(self, "right_panel_buttons") and "select" in self.right_panel_buttons:
+ self.right_panel_buttons["select"][0].setChecked(True)
+ self.canvas.set_edit_tool("select")
+ self.status.setText("Режим редактирования схемы включен. Доступны инструменты на панели справа.")
+ else:
+ self.canvas.set_edit_tool("select")
+ add_led_action = self.actions.get("add_led")
+ if add_led_action is not None and add_led_action.isChecked():
+ add_led_action.setChecked(False)
+ self.canvas.set_add_led_enabled(False)
+ self.status.setText("Режим редактирования схемы выключен.")
+
+ self._refresh_ui_state()
+
+ def _on_edit_tool_changed(self, tool: str) -> None:
+ self.canvas.set_edit_tool(tool)
+ add_led_action = self.actions.get("add_led")
+ if add_led_action is not None:
+ add_led_action.setChecked(tool == "draw")
+
+ if tool == "draw":
+ self.status.setText("Инструмент рисования LED: кликните по схеме или проведите мышью, чтобы поставить светодиоды.")
+ elif tool == "move":
+ self.status.setText("Инструмент перемещения LED: перетаскивайте светодиоды мышью без использования клавиши Alt.")
+ else:
+ self.status.setText("Инструмент выделения LED: выделяйте светодиоды рамкой или кликом.")
+ self.canvas._apply_default_cursor()
+
+ def _build_right_edit_panel(self) -> None:
+ self.right_panel = QFrame()
+ self.right_panel.setFixedWidth(44)
+
+ self.right_panel.setStyleSheet(
+ "QFrame { background-color: #1e293b; border-left: 1px solid #475569; }"
+ if effective_theme(self.settings.theme) == "dark" else
+ "QFrame { background-color: #f8fafc; border-left: 1px solid #cbd5e1; }"
+ )
+
+ layout = QVBoxLayout(self.right_panel)
+ layout.setContentsMargins(4, 10, 4, 10)
+ layout.setSpacing(8)
+ layout.setAlignment(Qt.AlignTop)
+
+ self.edit_tool_group = QButtonGroup(self)
+ self.edit_tool_group.setExclusive(True)
+
+ self.right_panel_buttons = {}
+ icon_color = self._icon_color()
+
+ tools = [
+ ("select", "mouse-pointer", "Инструмент выделения (Select)", lambda: self._on_edit_tool_changed("select")),
+ ("move", "move", "Инструмент перемещения (Move)", lambda: self._on_edit_tool_changed("move")),
+ ("draw", "plus-circle", "Инструмент рисования светодиодов (Draw)", lambda: self._on_edit_tool_changed("draw")),
+ ]
+
+ for key, icon, tooltip, handler in tools:
+ btn = QToolButton(self.right_panel)
+ btn.setCheckable(True)
+ btn.setIcon(lucide_icon(icon, icon_color))
+ btn.setIconSize(QSize(20, 20))
+ btn.setAutoRaise(True)
+ btn.setToolTip(tooltip)
+ btn.clicked.connect(handler)
+ if key == "select":
+ btn.setChecked(True)
+ self.edit_tool_group.addButton(btn)
+ layout.addWidget(btn, 0, Qt.AlignHCenter)
+ self.right_panel_buttons[key] = (btn, icon)
+
+ sep = QFrame(self.right_panel)
+ sep.setFrameShape(QFrame.Shape.HLine)
+ sep.setFrameShadow(QFrame.Shadow.Sunken)
+ sep.setStyleSheet("color: #475569; margin: 6px 0;")
+ layout.addWidget(sep)
+
+ actions = [
+ ("align_horiz", "align-horizontal", "Выровнять по горизонтали (вдоль средней линии Y)", self.align_selected_horizontally),
+ ("align_vert", "align-vertical", "Выровнять по вертикали (вдоль средней линии X)", self.align_selected_vertically),
+ ("grid_horiz", "grid-horizontal", "Расставить по горизонтали с шагом 34 мм", self.arrange_selected_horizontally_grid),
+ ("grid_vert", "grid-vertical", "Расставить по вертикали с шагом 34 мм", self.arrange_selected_vertically_grid),
+ ("align_grid", "grid", "Выровнять по сетке (по центру клеток)", self.align_selected_to_grid),
+ ]
+
+ for key, icon, tooltip, handler in actions:
+ btn = QToolButton(self.right_panel)
+ btn.setIcon(lucide_icon(icon, icon_color))
+ btn.setIconSize(QSize(20, 20))
+ btn.setAutoRaise(True)
+ btn.setToolTip(tooltip)
+ btn.clicked.connect(handler)
+ layout.addWidget(btn, 0, Qt.AlignHCenter)
+ self.right_panel_buttons[key] = (btn, icon)
+
+ layout.addStretch(1)
+ self.right_panel.setVisible(False)
+
+ def update_multiple_points_coordinates(self, coordinates: dict[int, tuple[float, float]], description: str) -> None:
+ if not coordinates:
+ return
+ self._push_undo()
+ for point_id, (x, y) in coordinates.items():
+ point = self.document.get_point(point_id)
+ self.document.update_point_position(point.uid, x=x, y=y)
+ self._dirty = True
+ self.canvas.set_document(self.document, reset_view=False)
+ selected_ids = list(coordinates.keys())
+ self.canvas.select_points(selected_ids)
+ self.refresh_tree()
+ self._on_selection_changed(selected_ids)
+ self.status.setText(description)
+
+ def align_selected_horizontally(self) -> None:
+ selected_ids = self.canvas.selected_point_ids()
+ if len(selected_ids) < 2:
+ self.status.setText("Выделите хотя бы 2 светодиода для выравнивания.")
+ return
+ points = [self.document.get_point(pid) for pid in selected_ids]
+ avg_y = sum(p.y for p in points) / len(points)
+ coords = {p.uid: (p.x, avg_y) for p in points}
+ self.update_multiple_points_coordinates(coords, "Светодиоды выровнены по горизонтали.")
+
+ def align_selected_vertically(self) -> None:
+ selected_ids = self.canvas.selected_point_ids()
+ if len(selected_ids) < 2:
+ self.status.setText("Выделите хотя бы 2 светодиода для выравнивания.")
+ return
+ points = [self.document.get_point(pid) for pid in selected_ids]
+ avg_x = sum(p.x for p in points) / len(points)
+ coords = {p.uid: (avg_x, p.y) for p in points}
+ self.update_multiple_points_coordinates(coords, "Светодиоды выровнены по вертикали.")
+
+ def arrange_selected_horizontally_grid(self) -> None:
+ selected_ids = self.canvas.selected_point_ids()
+ if len(selected_ids) < 2:
+ self.status.setText("Выделите хотя бы 2 светодиода для расстановки.")
+ return
+ points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.x)
+ first_p = points[0]
+ coords = {}
+ for i, p in enumerate(points):
+ new_x = first_p.x + i * 34.0
+ coords[p.uid] = (new_x, first_p.y)
+ self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по горизонтальной сетке с шагом 34 мм.")
+ def arrange_selected_vertically_grid(self) -> None:
+ selected_ids = self.canvas.selected_point_ids()
+ if len(selected_ids) < 2:
+ self.status.setText("Выделите хотя бы 2 светодиода для расстановки.")
+ return
+ points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.y, reverse=True)
+ first_p = points[0]
+ coords = {}
+ for i, p in enumerate(points):
+ new_y = first_p.y - i * 34.0
+ coords[p.uid] = (first_p.x, new_y)
+ self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по вертикальной сетке с шагом 34 мм.")
+
+ def align_selected_to_grid(self) -> None:
+ selected_ids = self.canvas.selected_point_ids()
+ if not selected_ids:
+ self.status.setText("Выделите светодиоды для выравнивания по сетке.")
+ return
+
+ points = [self.document.get_point(pid) for pid in selected_ids]
+
+ # We need grid anchors to calculate the grid origin.
+ # Find points that are NOT manual, or fallback to any points.
+ anchors = [p for p in self.document.points if not p.manual]
+ if not anchors:
+ anchors = self.document.points
+
+ if not anchors:
+ self.status.setText("Нет опорных точек для расчета сетки.")
+ return
+
+ from models import coordinate_display_scale, infer_grid_coordinate_axes, MIN_NEIGHBOR_DISTANCE_MM
+ scale = coordinate_display_scale(anchors)
+ step = MIN_NEIGHBOR_DISTANCE_MM / scale
+ row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
+
+ ref = anchors[0]
+ coords = {}
+
+ for p in points:
+ row, col = self.document._grid_cell_for_coordinate(p.x, p.y, exclude_uid=p.uid)
+ if row_axis == "x":
+ new_x = ref.x + (row - ref.row) * step * row_sign
+ new_y = ref.y + (col - ref.col) * step * col_sign
+ else:
+ new_y = ref.y + (row - ref.row) * step * row_sign
+ new_x = ref.x + (col - ref.col) * step * col_sign
+ coords[p.uid] = (new_x, new_y)
+
+ self.update_multiple_points_coordinates(coords, "Светодиоды выровнены по сетке (по центру клеток).")
def open_background_dialog(self) -> None:
dialog = BackgroundDialog(self.document.background, self)
@@ -919,6 +1165,81 @@ class MainWindow(QMainWindow):
self.sidebar_tab.setIcon(lucide_icon("chevron-right", icon_color))
for button in self.block_append_buttons.values():
button.setIcon(lucide_icon("plus", icon_color))
+
+ # Refresh right panel icons and styling
+ for key, (button, icon_name) in getattr(self, "right_panel_buttons", {}).items():
+ button.setIcon(lucide_icon(icon_name, icon_color))
+
+ is_dark = (effective_theme(self.settings.theme) == "dark")
+ if hasattr(self, "right_panel") and self.right_panel is not None:
+ if is_dark:
+ self.right_panel.setStyleSheet("""
+ QFrame { background-color: #1e293b; border-left: 1px solid #475569; }
+ QToolButton {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 3px;
+ }
+ QToolButton:hover {
+ background-color: #334155;
+ }
+ QToolButton:checked {
+ background-color: #1e3a8a;
+ border-color: #3b82f6;
+ }
+ """)
+ else:
+ self.right_panel.setStyleSheet("""
+ QFrame { background-color: #f8fafc; border-left: 1px solid #cbd5e1; }
+ QToolButton {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 3px;
+ }
+ QToolButton:hover {
+ background-color: #e2e8f0;
+ }
+ QToolButton:checked {
+ background-color: #dbeafe;
+ border-color: #3b82f6;
+ }
+ """)
+
+ if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None:
+ if is_dark:
+ self.menu_buttons_widget.setStyleSheet("""
+ QWidget { background: transparent; }
+ QFrame { color: #cbd5e1; margin: 2px 0; }
+ QToolButton {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 2px;
+ }
+ QToolButton:hover {
+ background-color: #334155;
+ }
+ QToolButton:checked {
+ background-color: #1e3a8a;
+ border-color: #3b82f6;
+ }
+ """)
+ else:
+ self.menu_buttons_widget.setStyleSheet("""
+ QWidget { background: transparent; }
+ QFrame { color: #475569; margin: 2px 0; }
+ QToolButton {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 2px;
+ }
+ QToolButton:hover {
+ background-color: #e2e8f0;
+ }
+ QToolButton:checked {
+ background-color: #dbeafe;
+ border-color: #3b82f6;
+ }
+ """)
def collapse_sidebar(self) -> None:
if self.sidebar_splitter is None or self.sidebar_stack is None or self._sidebar_collapsed:
@@ -1320,6 +1641,29 @@ class MainWindow(QMainWindow):
def move_point_from_canvas(self, point_id: int, x: float, y: float) -> None:
self.update_point_coordinates(point_id, x, y, source="drag")
+ def move_points_from_canvas(self, moves: list[tuple[int, float, float]]) -> None:
+ if not moves:
+ return
+ self._push_undo()
+ for point_id, x, y in moves:
+ point = self.document.get_point(point_id)
+ self.document.update_point_position(point.uid, x=x, y=y)
+
+ self._dirty = True
+ self.canvas.set_document(self.document, reset_view=False)
+
+ # Restore selection
+ selected_ids = [m[0] for m in moves]
+ self.canvas.select_points(selected_ids)
+ self.refresh_tree()
+ self._on_selection_changed(selected_ids)
+
+ if len(moves) == 1:
+ point = self.document.get_point(moves[0][0])
+ self.status.setText(f"LED ID {point.source_id}: координаты обновлены.")
+ else:
+ self.status.setText(f"Перемещено светодиодов: {len(moves)}.")
+
def update_point_coordinates(self, point_id: int, x: float, y: float, *, source: str = "edit") -> bool:
if not 0 <= point_id < len(self.document.points):
return False
diff --git a/qa_smoke.py b/qa_smoke.py
index fc6487c..9780efc 100644
--- a/qa_smoke.py
+++ b/qa_smoke.py
@@ -130,6 +130,7 @@ def main() -> None:
test_block_renaming_and_naming_strategy,
test_line_renaming_and_tree_shortcuts,
test_new_file_action,
+ test_scheme_editing_mode_actions,
test_menu_bar_tools_buttons,
test_export_mapping_to_image,
test_save_preserves_unknown_source_fields,
@@ -1186,6 +1187,84 @@ def test_new_file_action() -> None:
window.close()
+def test_scheme_editing_mode_actions() -> None:
+ if MainWindow is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ window = MainWindow()
+
+ # Load a document with 3 points
+ doc = MappingDocument()
+ from models import LedPoint
+ doc.points = [
+ LedPoint(uid=0, source_id=0, x=10.0, y=20.0, col=0, row=0),
+ LedPoint(uid=1, source_id=1, x=15.0, y=10.0, col=1, row=0),
+ LedPoint(uid=2, source_id=2, x=30.0, y=40.0, col=2, row=0),
+ ]
+ window.document = doc
+ window.canvas.set_document(doc, reset_view=True)
+
+ # 1. Edit mode visibility and toggling
+ assert window.right_panel is not None
+ assert window.right_panel.isHidden()
+
+ edit_action = window.actions.get("toggle_edit_mode")
+ assert edit_action is not None
+ edit_action.setChecked(True)
+ window.toggle_edit_mode()
+ assert not window.right_panel.isHidden()
+ assert window.canvas._edit_tool == "select"
+
+ # Switch to move tool
+ window._on_edit_tool_changed("move")
+ assert window.canvas._edit_tool == "move"
+ assert not window.canvas._add_led_enabled
+
+ # Switch to draw tool
+ window._on_edit_tool_changed("draw")
+ assert window.canvas._edit_tool == "draw"
+ assert window.canvas._add_led_enabled
+
+ # Switch back to select
+ window._on_edit_tool_changed("select")
+ assert window.canvas._edit_tool == "select"
+
+ # Select point 0 and 1
+ window.canvas.select_points([0, 1])
+ assert window.canvas.selected_point_ids() == [0, 1]
+
+ # 2. Align Horizontally (Y becomes average of 20 and 10 -> 15)
+ window.align_selected_horizontally()
+ assert abs(window.document.get_point(0).y - 15.0) < 1e-5
+ assert abs(window.document.get_point(1).y - 15.0) < 1e-5
+ assert abs(window.document.get_point(2).y - 40.0) < 1e-5 # unchanged
+
+ # Undo
+ window.undo_last_change()
+ assert abs(window.document.get_point(0).y - 20.0) < 1e-5
+ assert abs(window.document.get_point(1).y - 10.0) < 1e-5
+
+ # 3. Align Vertically (X becomes average of 10 and 15 -> 12.5)
+ window.canvas.select_points([0, 1])
+ window.align_selected_vertically()
+ assert abs(window.document.get_point(0).x - 12.5) < 1e-5
+ assert abs(window.document.get_point(1).x - 12.5) < 1e-5
+
+ # Undo
+ window.undo_last_change()
+ assert abs(window.document.get_point(0).x - 10.0) < 1e-5
+ assert abs(window.document.get_point(1).x - 15.0) < 1e-5
+
+ # 4. Grid Horizontal (Point 0 remains at 10, point 1 placed at 10 + 34 = 44)
+ window.canvas.select_points([0, 1])
+ window.arrange_selected_horizontally_grid()
+ assert abs(window.document.get_point(0).x - 10.0) < 1e-5
+ assert abs(window.document.get_point(1).x - 44.0) < 1e-5
+ assert abs(window.document.get_point(0).y - 20.0) < 1e-5
+ assert abs(window.document.get_point(1).y - 20.0) < 1e-5 # Y aligns to first point's Y
+
+ window.close()
+
def test_menu_bar_tools_buttons() -> None:
if MainWindow is None or QApplication is None:
@@ -1208,10 +1287,10 @@ def test_menu_bar_tools_buttons() -> None:
assert len(frames) == 1
buttons = container.findChildren(QToolButton)
- assert len(buttons) == 6
+ assert len(buttons) == 7
button_actions = [btn.defaultAction() for btn in buttons]
- expected_keys = ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush"]
+ expected_keys = ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush", "toggle_edit_mode"]
for key in expected_keys:
assert window.actions[key] in button_actions