Implement LED layout editing tools and infinite grid mode
- Добавлена bbox-подсветка с синим пунктиром вокруг выделенных светодиодов - Реализовано перетаскивание выделенной группы светодиодов за любую область внутри bbox-рамки - Добавлен инструмент «Выровнять по сетке» (Snap to Grid) на правую панель - Добавлена поддержка бесконечной сетки во все стороны в режиме редактирования - Исправлено прореживание и масштабирование сетки в режиме редактирования - Добавлены пункты выделения линий и блоков по правому клику мыши - Стабилизировано положение камеры при добавлении точек (устранено смещение scrolling-вьюпорта)
This commit is contained in:
parent
3fe3afa3d7
commit
691bfee068
382
canvas.py
382
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)
|
||||
|
||||
|
||||
8
icons.py
8
icons.py
@ -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"/>',
|
||||
}
|
||||
|
||||
|
||||
|
||||
354
main_window.py
354
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
|
||||
|
||||
83
qa_smoke.py
83
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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user