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