Implement LED layout editing tools and infinite grid mode

- Добавлена bbox-подсветка с синим пунктиром вокруг выделенных светодиодов
- Реализовано перетаскивание выделенной группы светодиодов за любую область внутри bbox-рамки
- Добавлен инструмент «Выровнять по сетке» (Snap to Grid) на правую панель
- Добавлена поддержка бесконечной сетки во все стороны в режиме редактирования
- Исправлено прореживание и масштабирование сетки в режиме редактирования
- Добавлены пункты выделения линий и блоков по правому клику мыши
- Стабилизировано положение камеры при добавлении точек (устранено смещение scrolling-вьюпорта)
This commit is contained in:
Данил Ярош 2026-08-07 14:03:41 +03:00
parent 3fe3afa3d7
commit 691bfee068
4 changed files with 785 additions and 42 deletions

382
canvas.py
View File

@ -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)

View File

@ -30,6 +30,14 @@ LUCIDE_PATHS = {
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.72l.15-.1a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"trash-2": '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
"undo-2": '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5A5.5 5.5 0 0 1 20 14.5 5.5 5.5 0 0 1 14.5 20H11"/>',
"pencil": '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
"mouse-pointer": '<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/>',
"move": '<path d="m5 9-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
"plus-circle": '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
"align-horizontal": '<line x1="2" x2="22" y1="12" y2="12"/><rect width="4" height="6" x="5" y="9" rx="1"/><rect width="4" height="6" x="15" y="9" rx="1"/>',
"align-vertical": '<line x1="12" x2="12" y1="2" y2="22"/><rect width="6" height="4" x="9" y="5" rx="1"/><rect width="6" height="4" x="9" y="15" rx="1"/>',
"grid-horizontal": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18M15 3v18"/>',
"grid-vertical": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18M3 15h18"/>',
}

View File

@ -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

View File

@ -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