From d8c95a15e26c0a9d6a801b5a6891ab25b31ee951 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Mon, 25 May 2026 13:39:59 +0300 Subject: [PATCH] feat: Improve editor overlays and stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена подложка с видимостью, прозрачностью, масштабом, перемещением и редактированием через отдельное меню. - Добавлен инструмент создания свободных светодиодов и редактирование X/Y для вручную созданных точек в таблице. - Упрощена таблица выбранных LED: единый ID, координаты X/Y и UID в конце. - Добавлены настраиваемые горячие клавиши для всех действий и переключатель отображения id светодиодов. - Улучшена отрисовка линий: стрелки направления, изгибы при полном и частичном визуальном наложении сегментов. - Оптимизировано переключение активных линий, выбор точек и поиск наложений соединений для снижения подвисаний. - Исправлена логика ручных LED: row/col рассчитываются по сетке 34 мм, а ручные точки не сдвигают якорь сетки. - Обновлена сборка EXE: имя использует тег, короткий хэш или дату/время для dirty-репозитория. - Расширен smoke-набор до 113 проверок для новых функций и регрессий. --- LEDMapper.spec | 13 + background_dialog.py | 106 ++++++ canvas.py | 695 ++++++++++++++++++++++++++++++++++++-- icons.py | 1 + main_window.py | 434 +++++++++++++++++++++--- models.py | 130 ++++++++ qa_smoke.py | 772 ++++++++++++++++++++++++++++++++++++++++++- settings.py | 27 +- settings_dialog.py | 4 + theme.py | 16 + 10 files changed, 2102 insertions(+), 96 deletions(-) create mode 100644 background_dialog.py diff --git a/LEDMapper.spec b/LEDMapper.spec index 0395bc9..11fc3cd 100644 --- a/LEDMapper.spec +++ b/LEDMapper.spec @@ -2,12 +2,24 @@ import subprocess from pathlib import Path +from datetime import datetime spec_dir = Path(SPECPATH) def git_build_ref(): + try: + dirty = subprocess.run( + ['git', 'diff-index', '--quiet', 'HEAD', '--'], + cwd=spec_dir, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ).returncode != 0 + if dirty: + return datetime.now().strftime('%H%M%S-%d%m%Y') + except OSError: + return datetime.now().strftime('%H%M%S-%d%m%Y') try: tag = subprocess.check_output( ['git', 'describe', '--tags', '--exact-match', 'HEAD'], @@ -43,6 +55,7 @@ a = Analysis( datas=[(str(spec_dir / 'assets'), 'assets')], hiddenimports=[ 'canvas', + 'background_dialog', 'icons', 'localization', 'main_window', diff --git a/background_dialog.py b/background_dialog.py new file mode 100644 index 0000000..70dfae0 --- /dev/null +++ b/background_dialog.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QCheckBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLineEdit, + QPushButton, + QWidget, +) + +try: + from .localization import localize_dialog_buttons + from .models import BackgroundImage +except ImportError: + from localization import localize_dialog_buttons + from models import BackgroundImage + + +class BackgroundDialog(QDialog): + def __init__(self, background: BackgroundImage, parent=None) -> None: # noqa: ANN001 + super().__init__(parent) + self.setWindowTitle("Подложка") + self.setMinimumWidth(460) + + self.path = QLineEdit(background.path) + self.path.setPlaceholderText("PNG или JPEG") + browse = QPushButton("Выбрать") + browse.clicked.connect(self._browse) + path_row = QWidget() + path_layout = QHBoxLayout(path_row) + path_layout.setContentsMargins(0, 0, 0, 0) + path_layout.addWidget(self.path, 1) + path_layout.addWidget(browse) + + self.offset_x = self._spin(-100000.0, 100000.0, background.offset_x, "") + self.offset_y = self._spin(-100000.0, 100000.0, background.offset_y, "") + self.scale_x = self._spin(1.0, 10000.0, background.scale_x_percent, " %") + self.scale_y = self._spin(1.0, 10000.0, background.scale_y_percent, " %") + self.opacity = self._spin(0.0, 100.0, background.opacity_percent, " %") + self.visible = QCheckBox("Показывать подложку") + self.visible.setChecked(background.visible) + reset_scale = QPushButton("100%") + reset_scale.clicked.connect(self._reset_scale) + scale_row = QWidget() + scale_layout = QHBoxLayout(scale_row) + scale_layout.setContentsMargins(0, 0, 0, 0) + scale_layout.addWidget(self.scale_x, 1) + scale_layout.addWidget(self.scale_y, 1) + scale_layout.addWidget(reset_scale) + + form = QFormLayout(self) + form.addRow("Изображение", path_row) + form.addRow("Смещение центра X", self.offset_x) + form.addRow("Смещение центра Y", self.offset_y) + form.addRow("Масштаб X / Y", scale_row) + form.addRow("Прозрачность", self.opacity) + form.addRow("", self.visible) + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + localize_dialog_buttons(buttons) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + form.addRow(buttons) + + def background(self) -> BackgroundImage: + return BackgroundImage( + path=self.path.text().strip(), + offset_x=self.offset_x.value(), + offset_y=self.offset_y.value(), + scale_x_percent=self.scale_x.value(), + scale_y_percent=self.scale_y.value(), + opacity_percent=self.opacity.value(), + visible=self.visible.isChecked(), + ) + + def _reset_scale(self) -> None: + self.scale_x.setValue(100.0) + self.scale_y.setValue(100.0) + + def _browse(self) -> None: + start = self.path.text().strip() + start_path = Path(start).parent if start else Path.home() + selected, _ = QFileDialog.getOpenFileName( + self, + "Выбрать подложку", + str(start_path), + "Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)", + ) + if selected: + self.path.setText(selected) + + @staticmethod + def _spin(minimum: float, maximum: float, value: float, suffix: str) -> QDoubleSpinBox: + spin = QDoubleSpinBox() + spin.setRange(minimum, maximum) + spin.setDecimals(2) + spin.setSingleStep(1.0) + spin.setValue(value) + spin.setSuffix(suffix) + return spin diff --git a/canvas.py b/canvas.py index bd4fff5..8af2409 100644 --- a/canvas.py +++ b/canvas.py @@ -1,15 +1,19 @@ from __future__ import annotations import math +from pathlib import Path -from PySide6.QtCore import QPointF, QRectF, Qt, Signal -from PySide6.QtGui import QColor, QBrush, QPainter, QPainterPath, QPen +from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtGui import QColor, QBrush, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform from PySide6.QtWidgets import ( QGraphicsEllipseItem, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, + QGraphicsPixmapItem, + QGraphicsRectItem, QGraphicsScene, + QGraphicsSimpleTextItem, QGraphicsView, QMenu, QScrollBar, @@ -17,9 +21,12 @@ from PySide6.QtWidgets import ( from shiboken6 import isValid try: - from .models import LedLine, LedPoint, MappingDocument, coordinate_display_scale + from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale except ImportError: - from models import LedLine, LedPoint, MappingDocument, coordinate_display_scale + from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale + + +MAX_BACKGROUND_PIXMAP_SIDE = 8192 class LedItem(QGraphicsEllipseItem): @@ -52,11 +59,16 @@ class LedCanvas(QGraphicsView): brushStrokeStarted = Signal(bool) brushPointTouched = Signal(int) brushStrokeFinished = Signal() + addLedRequested = Signal(float, float) + backgroundEditStarted = Signal() + backgroundChanged = Signal() + backgroundEditFinished = Signal(bool) def __init__(self) -> None: super().__init__() self.setScene(QGraphicsScene(self)) self.setRenderHint(QPainter.Antialiasing) + self.setRenderHint(QPainter.SmoothPixmapTransform) self.setMouseTracking(True) self.viewport().setMouseTracking(True) self.setDragMode(QGraphicsView.RubberBandDrag) @@ -67,7 +79,17 @@ class LedCanvas(QGraphicsView): self.items_by_id: dict[int, LedItem] = {} self.grid_items: list[QGraphicsLineItem] = [] self.connection_items: list[QGraphicsPathItem] = [] + self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {} + self._background_item: QGraphicsPixmapItem | None = None + self._background_outline: QGraphicsRectItem | None = None + self._background_handles: dict[str, QGraphicsRectItem] = {} + self._background_edit_mode = False + self._background_drag: dict | None = None + self._background_pixmap_cache_key: tuple[str, int | None] | None = None + self._background_pixmap_cache: QPixmap | None = None + self._background_loaded = False self._direction_arrows_visible = True + self._line_index_labels_visible = True self._point_radius = 4.5 self._brush_radius = 18.0 self._zoom_focus_pull = 0.35 @@ -86,6 +108,7 @@ class LedCanvas(QGraphicsView): self._last_pan_pos = None self._last_mouse_pos = None self._brush_enabled = False + self._add_led_enabled = False self._is_brush_stroke = False self._brush_touched_ids: set[int] = set() self._brush_preview_ids: set[int] = set() @@ -104,7 +127,7 @@ class LedCanvas(QGraphicsView): if self.active_line_id == line_id: return self.active_line_id = line_id - self.refresh_styles() + self.refresh_styles(redraw_connections=False) def set_grid_visible(self, visible: bool) -> None: self._grid_visible = visible @@ -119,6 +142,15 @@ class LedCanvas(QGraphicsView): def direction_arrows_visible(self) -> bool: return self._direction_arrows_visible + def set_line_index_labels_visible(self, visible: bool) -> None: + if self._line_index_labels_visible == visible: + return + self._line_index_labels_visible = visible + self._refresh_line_index_labels() + + def line_index_labels_visible(self) -> bool: + return self._line_index_labels_visible + def set_brush_radius(self, radius: int) -> None: self._brush_radius = float(max(4, min(80, radius))) if self._brush_zone_item is not None and isValid(self._brush_zone_item): @@ -155,6 +187,8 @@ class LedCanvas(QGraphicsView): def set_brush_enabled(self, enabled: bool) -> None: self._brush_enabled = enabled + if enabled: + self._add_led_enabled = False self._is_brush_stroke = False self._clear_brush_state() self.setDragMode(QGraphicsView.NoDrag if enabled else QGraphicsView.RubberBandDrag) @@ -164,12 +198,76 @@ class LedCanvas(QGraphicsView): self._hide_brush_zone() self._apply_default_cursor() + def set_add_led_enabled(self, enabled: bool) -> None: + self._add_led_enabled = enabled + if enabled: + self.set_brush_enabled(False) + self.set_background_edit_mode(False) + self._apply_default_cursor() + + def add_led_enabled(self) -> bool: + return self._add_led_enabled + def set_brush_excluded_point_ids(self, point_ids: set[int] | list[int]) -> None: self._brush_excluded_point_ids = set(point_ids) changed_ids = self._brush_preview_ids | self._brush_touched_ids self._brush_preview_ids.difference_update(self._brush_excluded_point_ids) self._refresh_brush_preview_styles(changed_ids) + def set_background_edit_mode(self, enabled: bool) -> None: + self._background_edit_mode = enabled + if enabled: + self._add_led_enabled = False + if enabled and self._brush_enabled: + self.set_brush_enabled(False) + if not enabled: + self._background_drag = None + self._update_background_handles() + self._apply_default_cursor() + + def background_edit_mode(self) -> bool: + return self._background_edit_mode + + def background_loaded(self) -> bool: + return self._background_loaded + + def set_background(self, background: BackgroundImage) -> None: + if self.document is None: + return + view_state = self._capture_view_state() + self.document.background = background + self._refresh_background() + self._update_scene_rect() + self._restore_view_state(view_state) + + def clear_background(self) -> None: + if self.document is None: + return + view_state = self._capture_view_state() + self.document.background = BackgroundImage() + self._refresh_background() + self._update_scene_rect() + self._restore_view_state(view_state) + + def reset_background_scale(self) -> None: + if self.document is None: + return + view_state = self._capture_view_state() + self.document.background.scale_x_percent = 100.0 + self.document.background.scale_y_percent = 100.0 + self._apply_background_geometry() + self._restore_view_state(view_state) + self.backgroundChanged.emit() + + def set_background_visible(self, visible: bool) -> None: + if self.document is None or not self.document.background.is_enabled(): + return + view_state = self._capture_view_state() + self.document.background.visible = visible + self._apply_background_geometry() + self._restore_view_state(view_state) + self.backgroundChanged.emit() + def rebuild(self, *, reset_view: bool = True) -> None: old_transform = self.transform() old_center = self.mapToScene(self.viewport().rect().center()) @@ -180,6 +278,11 @@ class LedCanvas(QGraphicsView): self.items_by_id.clear() self.grid_items.clear() self.connection_items.clear() + self.line_index_label_items.clear() + self._background_item = None + self._background_outline = None + self._background_handles.clear() + self._background_drag = None self._brush_zone_item = None self._brush_preview_ids.clear() self._brush_touched_ids.clear() @@ -191,6 +294,8 @@ class LedCanvas(QGraphicsView): self._raw_bounds = None self._content_scene_rect = None if self.document is None or not self.document.points: + self._refresh_background() + self._update_scene_rect() return self._display_scale = coordinate_display_scale(self.document.points) @@ -213,7 +318,8 @@ class LedCanvas(QGraphicsView): max(max_x - min_x, 1.0) + self._margin * 2, max(max_y - min_y, 1.0) + self._margin * 2, ) - self.scene().setSceneRect(self._content_scene_rect) + self._refresh_background() + self._update_scene_rect() self._refresh_grid() self.refresh_styles() if reset_view: @@ -227,6 +333,8 @@ class LedCanvas(QGraphicsView): return if redraw_connections: self._clear_connections() + self._connection_segments: list[tuple[QPointF, QPointF]] = [] + self._connection_segment_buckets: dict[tuple[int, int], list[tuple[QPointF, QPointF]]] = {} line_by_id = {line.id: line for line in self.document.lines} for point in self.document.points: item = self.items_by_id.get(point.uid) @@ -253,7 +361,10 @@ class LedCanvas(QGraphicsView): if redraw_connections: for line in self.document.lines: self._draw_line_connections(line) + else: + self._restyle_connection_items() self._refresh_brush_preview_styles(self._brush_preview_ids | self._brush_touched_ids) + self._refresh_line_index_labels() def selected_point_ids(self) -> list[int]: return sorted( @@ -266,11 +377,13 @@ class LedCanvas(QGraphicsView): target_ids = set(point_ids) if target_ids == self._last_selected_ids: return + changed_ids = target_ids ^ self._last_selected_ids scene = self.scene() scene.blockSignals(True) self.setUpdatesEnabled(False) try: - for item in self.items_by_id.values(): + for point_id in changed_ids: + item = self.items_by_id.get(point_id) if not isValid(item): continue should_select = item.point_id in target_ids @@ -281,19 +394,171 @@ class LedCanvas(QGraphicsView): scene.blockSignals(False) self._emit_selection() + def _refresh_background(self) -> None: + self._remove_background_items() + if self.document is None or not self.document.background.is_enabled(): + return + image_path = Path(self.document.background.path) + if not image_path.is_absolute() and self.document.path is not None: + image_path = self.document.path.parent / image_path + pixmap = self._load_background_pixmap(image_path) + if pixmap.isNull(): + self._background_loaded = False + return + self._background_loaded = True + self._background_item = self.scene().addPixmap(pixmap) + self._background_item.setTransformationMode(Qt.SmoothTransformation) + self._background_item.setZValue(-30) + self._background_item.setAcceptedMouseButtons(Qt.NoButton) + self._apply_background_geometry() + + def _remove_background_items(self) -> None: + for item in [self._background_item, self._background_outline, *self._background_handles.values()]: + if item is not None and isValid(item): + self.scene().removeItem(item) + self._background_item = None + self._background_outline = None + self._background_handles.clear() + self._background_loaded = False + + def _load_background_pixmap(self, path: Path) -> QPixmap: + cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None) + if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None: + return self._background_pixmap_cache + reader = QImageReader(str(path)) + size = reader.size() + if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE: + factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height()) + reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor)))) + image = reader.read() + pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap() + self._background_pixmap_cache_key = cache_key + self._background_pixmap_cache = pixmap + return pixmap + + def _apply_background_geometry(self) -> None: + if self.document is None or self._background_item is None or not isValid(self._background_item): + self._update_background_handles() + return + view_state = self._capture_view_state() + background = self.document.background + origin = self._background_origin_scene_point() + scale_x = max(1.0, background.scale_x_percent) / 100.0 + scale_y = max(1.0, background.scale_y_percent) / 100.0 + source_rect = self._background_item.boundingRect() + center_x = origin.x() + background.offset_x + center_y = origin.y() + background.offset_y + self._background_item.setVisible(background.visible) + self._background_item.setOpacity(max(0.0, min(1.0, background.opacity_percent / 100.0))) + self._background_item.setPos( + center_x - source_rect.width() * scale_x / 2.0, + center_y - source_rect.height() * scale_y / 2.0, + ) + self._background_item.setScale(1.0) + self._background_item.setTransformOriginPoint(0, 0) + self._background_item.setTransform( + QTransform.fromScale( + scale_x, + scale_y, + ) + ) + self._update_background_handles() + self._update_scene_rect() + self._restore_view_state(view_state) + + def _background_scene_rect(self) -> QRectF | None: + if self._background_item is None or not isValid(self._background_item): + return None + if self.document is not None and not self.document.background.visible: + return None + return self._background_item.sceneBoundingRect() + + def _update_scene_rect(self) -> None: + 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: + rect = QRectF(0, 0, 1, 1) + self.scene().setSceneRect(rect) + + def _update_background_handles(self) -> None: + rect = self._background_scene_rect() + if not self._background_edit_mode or rect is None: + for item in [self._background_outline, *self._background_handles.values()]: + if item is not None and isValid(item): + self.scene().removeItem(item) + self._background_outline = None + self._background_handles.clear() + return + pen = QPen(QColor("#fbbf24"), 1.2) + pen.setCosmetic(True) + if self._background_outline is None or not isValid(self._background_outline): + self._background_outline = self.scene().addRect(rect, pen) + self._background_outline.setZValue(60) + else: + self._background_outline.setRect(rect) + self._background_outline.setPen(pen) + handle_positions = { + "nw": rect.topLeft(), + "n": QPointF(rect.center().x(), rect.top()), + "ne": rect.topRight(), + "e": QPointF(rect.right(), rect.center().y()), + "se": rect.bottomRight(), + "s": QPointF(rect.center().x(), rect.bottom()), + "sw": rect.bottomLeft(), + "w": QPointF(rect.left(), rect.center().y()), + } + for name, point in handle_positions.items(): + handle = self._background_handles.get(name) + if handle is None or not isValid(handle): + handle = QGraphicsRectItem(-4, -4, 8, 8) + handle.setBrush(QColor("#fbbf24")) + handle.setPen(QPen(QColor("#111827"), 1.0)) + handle.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) + handle.setZValue(61) + handle.setAcceptedMouseButtons(Qt.NoButton) + self.scene().addItem(handle) + self._background_handles[name] = handle + handle.setPos(point) + def fit_to_content(self) -> None: - if self._content_scene_rect is not None: - self.scene().setSceneRect(self._content_scene_rect) + self._update_scene_rect() if self.scene() is None or self.sceneRect().isEmpty(): return self.fitInView(self.sceneRect(), Qt.KeepAspectRatio) + def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None: + if self.viewport().rect().isEmpty(): + return None + return ( + self.transform(), + self.mapToScene(self.viewport().rect().center()), + self.mapToScene(self.viewport().rect()).boundingRect(), + ) + + def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None: + if state is None: + return + transform, center, visible_rect = state + self.setTransform(transform) + scene_rect = self.scene().sceneRect() + if not scene_rect.contains(visible_rect): + self.scene().setSceneRect(scene_rect.united(visible_rect)) + self.centerOn(center) + def wheelEvent(self, event) -> None: # noqa: ANN001 + if self._background_drag is not None: + event.ignore() + return factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() self._zoom_at_viewport_pos(viewport_pos, factor) if self._brush_enabled: self._update_brush_zone(viewport_pos) + self._position_line_index_labels() event.accept() def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001 @@ -312,12 +577,14 @@ class LedCanvas(QGraphicsView): self.setTransformationAnchor(old_anchor) def _ensure_zoom_padding(self) -> None: - if self._content_scene_rect is None or self.viewport().rect().isEmpty(): + if self.viewport().rect().isEmpty(): return + self._update_scene_rect() + base_rect = self.scene().sceneRect() visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect() pad_x = visible_scene_rect.width() * 0.5 pad_y = visible_scene_rect.height() * 0.5 - self.scene().setSceneRect(self._content_scene_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y)) + self.scene().setSceneRect(base_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y)) def keyPressEvent(self, event) -> None: # noqa: ANN001 if event.key() == Qt.Key_Delete: @@ -353,6 +620,14 @@ class LedCanvas(QGraphicsView): self._start_pan(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()) + event.accept() + return if self._brush_enabled and event.button() == Qt.LeftButton: self._is_brush_stroke = True self._brush_touched_ids.clear() @@ -381,6 +656,10 @@ class LedCanvas(QGraphicsView): self._move_scrollbar(self.verticalScrollBar(), delta.y()) event.accept() return + if self._background_drag is not None: + self._move_background_edit(event.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()) @@ -401,6 +680,10 @@ class LedCanvas(QGraphicsView): self._finish_pan() event.accept() return + if event.button() == Qt.LeftButton and self._background_drag is not None: + self._finish_background_edit() + event.accept() + return if self._brush_enabled and event.button() == Qt.LeftButton and self._is_brush_stroke: self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) @@ -411,6 +694,112 @@ class LedCanvas(QGraphicsView): return super().mouseReleaseEvent(event) + 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): + return False + if not self.document.background.visible: + return False + scene_pos = self.mapToScene(viewport_pos) + rect = self._background_scene_rect() + if rect is None: + return False + handle_name = self._background_handle_at(viewport_pos) + if handle_name is None and not rect.contains(scene_pos): + return False + start_payload = self.document.background.to_payload() + self._background_drag = { + "mode": "resize" if handle_name else "move", + "handle": handle_name, + "start_pos": scene_pos, + "start_rect": QRectF(rect), + "start_background": BackgroundImage( + path=self.document.background.path, + offset_x=self.document.background.offset_x, + offset_y=self.document.background.offset_y, + scale_x_percent=self.document.background.scale_x_percent, + scale_y_percent=self.document.background.scale_y_percent, + opacity_percent=self.document.background.opacity_percent, + visible=self.document.background.visible, + ), + "start_payload": start_payload, + "undo_emitted": False, + "changed": False, + } + self._apply_default_cursor() + return True + + def _move_background_edit(self, viewport_pos) -> None: # noqa: ANN001 + if self.document is None or self._background_drag is None: + return + scene_pos = self.mapToScene(viewport_pos) + start_pos: QPointF = self._background_drag["start_pos"] + delta = scene_pos - start_pos + if abs(delta.x()) < 0.001 and abs(delta.y()) < 0.001: + return + if not self._background_drag.get("undo_emitted"): + self.backgroundEditStarted.emit() + self._background_drag["undo_emitted"] = True + self._background_drag["changed"] = True + start_rect: QRectF = self._background_drag["start_rect"] + start_background: BackgroundImage = self._background_drag["start_background"] + if self._background_drag["mode"] == "move": + self.document.background.offset_x = start_background.offset_x + delta.x() + self.document.background.offset_y = start_background.offset_y + delta.y() + else: + handle = str(self._background_drag["handle"]) + rect = QRectF(start_rect) + if "w" in handle: + rect.setLeft(min(rect.right() - 8.0, rect.left() + delta.x())) + if "e" in handle: + rect.setRight(max(rect.left() + 8.0, rect.right() + delta.x())) + if "n" in handle: + rect.setTop(min(rect.bottom() - 8.0, rect.top() + delta.y())) + if "s" in handle: + rect.setBottom(max(rect.top() + 8.0, rect.bottom() + delta.y())) + self._apply_background_rect(rect, start_background, start_rect) + self._apply_background_geometry() + + def _finish_background_edit(self) -> None: + changed = bool(self._background_drag and self._background_drag.get("changed")) + start_payload = self._background_drag.get("start_payload") if self._background_drag else None + self._background_drag = None + self._apply_default_cursor() + payload_changed = changed and self.document is not None and self.document.background.to_payload() != start_payload + if payload_changed: + self._update_scene_rect() + self.backgroundChanged.emit() + self.backgroundEditFinished.emit(bool(payload_changed)) + + def _background_handle_at(self, viewport_pos) -> str | None: # noqa: ANN001 + for name, handle in self._background_handles.items(): + if handle is not None and isValid(handle): + handle_pos = self.mapFromScene(handle.scenePos()) + if abs(handle_pos.x() - viewport_pos.x()) <= 8 and abs(handle_pos.y() - viewport_pos.y()) <= 8: + return name + return None + + def _apply_background_rect( + self, + rect: QRectF, + start_background: BackgroundImage, + start_rect: QRectF, + ) -> None: + if self.document is None: + return + origin = self._background_origin_scene_point() + self.document.background.offset_x = rect.center().x() - origin.x() + self.document.background.offset_y = rect.center().y() - origin.y() + if start_rect.width() > 0: + self.document.background.scale_x_percent = max( + 1.0, + start_background.scale_x_percent * rect.width() / start_rect.width(), + ) + if start_rect.height() > 0: + self.document.background.scale_y_percent = max( + 1.0, + start_background.scale_y_percent * rect.height() / start_rect.height(), + ) + def enterEvent(self, event) -> None: # noqa: ANN001 self._apply_default_cursor() super().enterEvent(event) @@ -423,9 +812,11 @@ class LedCanvas(QGraphicsView): def _apply_default_cursor(self) -> None: if self._is_panning: cursor_shape = Qt.ClosedHandCursor + elif self._background_drag is not None: + cursor_shape = Qt.ClosedHandCursor if self._background_drag.get("mode") == "move" else Qt.SizeAllCursor elif self._space_pressed and self._can_pan_with_space(): cursor_shape = Qt.OpenHandCursor - elif self._brush_enabled: + elif self._brush_enabled or self._add_led_enabled: cursor_shape = Qt.CrossCursor else: cursor_shape = Qt.ArrowCursor @@ -492,6 +883,7 @@ class LedCanvas(QGraphicsView): return self._refresh_selection_pens(selected_ids ^ self._last_selected_ids) self._last_selected_ids = selected_ids + self._refresh_line_index_labels() self.selectionChanged.emit(sorted(selected_ids)) def _refresh_selection_pens(self, point_ids: set[int]) -> None: @@ -509,6 +901,48 @@ class LedCanvas(QGraphicsView): except RuntimeError: continue + def _refresh_line_index_labels(self) -> None: + self._clear_line_index_labels() + if self.document is None or not self._line_index_labels_visible: + return + for point_id in sorted(self._last_selected_ids): + point = self.document.get_point(point_id) + if point.line_id is None or point.line_index is None: + continue + item = self.items_by_id.get(point_id) + if item is None or not isValid(item): + continue + label = QGraphicsSimpleTextItem(str(point.line_index)) + label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827")) + label.setZValue(40) + label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) + label.setAcceptedMouseButtons(Qt.NoButton) + self.scene().addItem(label) + self.line_index_label_items[point_id] = label + self._position_line_index_labels() + + def _position_line_index_labels(self) -> None: + if not self.line_index_label_items: + return + scale = max(abs(self.transform().m11()), 0.001) + scene_gap = 6.0 / scale + scene_radius = self._point_radius / scale + for point_id, label in list(self.line_index_label_items.items()): + item = self.items_by_id.get(point_id) + if item is None or not isValid(item) or not isValid(label): + continue + label_rect = label.boundingRect() + label.setPos( + item.pos().x() - label_rect.width() / 2.0, + item.pos().y() - scene_radius - scene_gap, + ) + + def _clear_line_index_labels(self) -> None: + for label in self.line_index_label_items.values(): + if isValid(label): + self.scene().removeItem(label) + self.line_index_label_items.clear() + def _touch_brush_point(self, viewport_pos) -> None: # noqa: ANN001 touched = self._brush_candidate_ids(viewport_pos) for point_id in sorted(touched): @@ -640,14 +1074,21 @@ class LedCanvas(QGraphicsView): right_x = max_x + self._margin bottom_y = min_y - self._margin top_y = max_y + self._margin + scene_rect = self.scene().sceneRect() + left_x = min(left_x, scene_rect.left() + min_x - self._margin) + right_x = max(right_x, scene_rect.right() + min_x - self._margin) + top_y = max(top_y, max_y + self._margin - scene_rect.top()) + bottom_y = min(bottom_y, max_y + self._margin - scene_rect.bottom()) first_col = math.floor((left_x - origin_x) / self._grid_step) last_col = math.ceil((right_x - origin_x) / self._grid_step) 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)) pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6) pen.setCosmetic(True) - for col in range(first_col, last_col + 1): + for col in range(first_col, last_col + 1, step_multiplier): x = origin_x + col * self._grid_step top = self._scene_point_from_display_units(x, top_y) bottom = self._scene_point_from_display_units(x, bottom_y) @@ -655,7 +1096,7 @@ class LedCanvas(QGraphicsView): item.setZValue(-20) self.grid_items.append(item) - for row in range(first_row, last_row + 1): + for row in range(first_row, last_row + 1, step_multiplier): y = origin_y + row * self._grid_step left = self._scene_point_from_display_units(left_x, y) right = self._scene_point_from_display_units(right_x, y) @@ -676,12 +1117,22 @@ class LedCanvas(QGraphicsView): for block in line.blocks: ordered_items = [self.items_by_id[point_id] for point_id in block.point_ids if point_id in self.items_by_id] for first, second in zip(ordered_items, ordered_items[1:]): - self._append_connection(inner_path, first.pos(), second.pos()) + start = first.pos() + end = second.pos() + control = self._append_connection(inner_path, start, end) if self._direction_arrows_visible: - self._append_arrow_marker(inner_arrows, first.pos(), second.pos()) - self._add_connection_path(inner_path, line.color, inner_width, inner_alpha) + self._append_arrow_marker(inner_arrows, start, end, control) + self._add_connection_path(inner_path, line.color, inner_width, inner_alpha, line_id=line.id, connection_kind="inner") if self._direction_arrows_visible: - self._add_connection_path(inner_arrows, line.color, arrow_width, arrow_alpha, z_value=2) + self._add_connection_path( + inner_arrows, + line.color, + arrow_width, + arrow_alpha, + z_value=2, + line_id=line.id, + connection_kind="arrow", + ) previous_block_end = None bridge_path = QPainterPath() @@ -692,31 +1143,58 @@ class LedCanvas(QGraphicsView): block_start = self.items_by_id.get(block.point_ids[0]) block_end = self.items_by_id.get(block.point_ids[-1]) if block_start is not None and previous_block_end is not None: - self._append_connection(bridge_path, previous_block_end.pos(), block_start.pos()) + start = previous_block_end.pos() + end = block_start.pos() + control = self._append_connection(bridge_path, start, end) if self._direction_arrows_visible: - self._append_arrow_marker(bridge_arrows, previous_block_end.pos(), block_start.pos()) + self._append_arrow_marker(bridge_arrows, start, end, control) if block_end is not None: previous_block_end = block_end - self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha) + self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha, line_id=line.id, connection_kind="bridge") if self._direction_arrows_visible: - self._add_connection_path(bridge_arrows, line.color, arrow_width, bridge_alpha, z_value=2) + self._add_connection_path( + bridge_arrows, + line.color, + arrow_width, + bridge_alpha, + z_value=2, + line_id=line.id, + connection_kind="bridge_arrow", + ) - @staticmethod - def _append_connection(path: QPainterPath, start: QPointF, end: QPointF) -> None: + def _append_connection(self, path: QPainterPath, start: QPointF, end: QPointF) -> QPointF | None: path.moveTo(start) - path.lineTo(end) + control = self._overlap_control_point(start, end) + if control is None: + path.lineTo(end) + else: + path.quadTo(control, end) + return control @staticmethod - def _append_arrow_marker(path: QPainterPath, start: QPointF, end: QPointF) -> None: + def _append_arrow_marker(path: QPainterPath, start: QPointF, end: QPointF, control: QPointF | None = None) -> None: dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 8.0: return - unit_x = dx / length - unit_y = dy / length - mid_x = start.x() + dx * 0.55 - mid_y = start.y() + dy * 0.55 + if control is None: + unit_x = dx / length + unit_y = dy / length + mid_x = start.x() + dx * 0.55 + mid_y = start.y() + dy * 0.55 + else: + t = 0.55 + inv_t = 1.0 - t + mid_x = inv_t * inv_t * start.x() + 2.0 * inv_t * t * control.x() + t * t * end.x() + mid_y = inv_t * inv_t * start.y() + 2.0 * inv_t * t * control.y() + t * t * end.y() + tangent_x = 2.0 * inv_t * (control.x() - start.x()) + 2.0 * t * (end.x() - control.x()) + tangent_y = 2.0 * inv_t * (control.y() - start.y()) + 2.0 * t * (end.y() - control.y()) + tangent_length = math.hypot(tangent_x, tangent_y) + if tangent_length < 0.001: + return + unit_x = tangent_x / tangent_length + unit_y = tangent_y / tangent_length arrow_length = min(7.0, length * 0.28) arrow_width = arrow_length * 0.55 base_x = mid_x - unit_x * arrow_length @@ -730,14 +1208,144 @@ class LedCanvas(QGraphicsView): path.lineTo(tip) path.lineTo(right) - def _add_connection_path(self, path: QPainterPath, color: str, width: float, alpha: float, *, z_value: float = 1) -> None: + def _overlap_control_point(self, start: QPointF, end: QPointF) -> QPointF | None: + segments = getattr(self, "_connection_segments", None) + if segments is None: + return None + candidates = self._overlap_candidates(start, end) + previous_count = sum( + 1 + for previous_start, previous_end in candidates + if self._segments_visually_overlap(start, end, previous_start, previous_end) + ) + segments.append((QPointF(start), QPointF(end))) + buckets = getattr(self, "_connection_segment_buckets", None) + if buckets is not None: + segment = (QPointF(start), QPointF(end)) + for bucket in self._segment_buckets(start, end): + buckets.setdefault(bucket, []).append(segment) + if previous_count == 0: + return None + dx = end.x() - start.x() + dy = end.y() - start.y() + length = math.hypot(dx, dy) + if length < 0.001: + return None + bend = min(16.0, max(7.0, length * 0.24)) + side = 1.0 if previous_count % 2 == 1 else -1.0 + layer = 1.0 + (previous_count - 1) // 2 + offset = side * bend * layer + perp_x = -dy / length + perp_y = dx / length + return QPointF( + (start.x() + end.x()) / 2.0 + perp_x * offset, + (start.y() + end.y()) / 2.0 + perp_y * offset, + ) + + def _overlap_candidates(self, start: QPointF, end: QPointF) -> list[tuple[QPointF, QPointF]]: + buckets = getattr(self, "_connection_segment_buckets", None) + if not buckets: + return getattr(self, "_connection_segments", []) + candidates: list[tuple[QPointF, QPointF]] = [] + seen: set[int] = set() + for bucket in self._segment_buckets(start, end): + for segment in buckets.get(bucket, []): + marker = id(segment) + if marker not in seen: + seen.add(marker) + candidates.append(segment) + return candidates + + @staticmethod + def _segment_buckets(start: QPointF, end: QPointF) -> set[tuple[int, int]]: + bucket_size = 96.0 + left = math.floor(min(start.x(), end.x()) / bucket_size) + right = math.floor(max(start.x(), end.x()) / bucket_size) + top = math.floor(min(start.y(), end.y()) / bucket_size) + bottom = math.floor(max(start.y(), end.y()) / bucket_size) + return { + (x_bucket, y_bucket) + for x_bucket in range(left, right + 1) + for y_bucket in range(top, bottom + 1) + } + + @staticmethod + def _segments_visually_overlap(a_start: QPointF, a_end: QPointF, b_start: QPointF, b_end: QPointF) -> bool: + ax = a_end.x() - a_start.x() + ay = a_end.y() - a_start.y() + bx = b_end.x() - b_start.x() + by = b_end.y() - b_start.y() + a_length = math.hypot(ax, ay) + b_length = math.hypot(bx, by) + if a_length < 0.001 or b_length < 0.001: + return False + + unit_ax = ax / a_length + unit_ay = ay / a_length + unit_bx = bx / b_length + unit_by = by / b_length + parallel_score = abs(unit_ax * unit_bx + unit_ay * unit_by) + if parallel_score < 0.985: + return False + + def distance_to_a_line(point: QPointF) -> float: + return abs((point.x() - a_start.x()) * unit_ay - (point.y() - a_start.y()) * unit_ax) + + if max(distance_to_a_line(b_start), distance_to_a_line(b_end)) > 5.0: + return False + + b0 = (b_start.x() - a_start.x()) * unit_ax + (b_start.y() - a_start.y()) * unit_ay + b1 = (b_end.x() - a_start.x()) * unit_ax + (b_end.y() - a_start.y()) * unit_ay + overlap_start = max(0.0, min(b0, b1)) + overlap_end = min(a_length, max(b0, b1)) + return (overlap_end - overlap_start) >= min(10.0, min(a_length, b_length) * 0.35) + + def _add_connection_path( + self, + path: QPainterPath, + color: str, + width: float, + alpha: float, + *, + z_value: float = 1, + line_id: int | None = None, + connection_kind: str = "inner", + ) -> None: if path.isEmpty(): return + item = self.scene().addPath(path) + item.setData(0, line_id) + item.setData(1, connection_kind) + item.setData(2, color) + item.setZValue(z_value) + self._apply_connection_item_style(item) + self.connection_items.append(item) + + def _restyle_connection_items(self) -> None: + for item in self.connection_items: + if isValid(item): + self._apply_connection_item_style(item) + + def _apply_connection_item_style(self, item: QGraphicsPathItem) -> None: + line_id = item.data(0) + kind = str(item.data(1) or "inner") + color = str(item.data(2) or "#e11d48") + is_active = self.active_line_id is None or line_id == self.active_line_id + if kind == "inner": + width = 2.8 if is_active else 1.2 + alpha = 0.9 if is_active else 0.25 + elif kind == "bridge": + width = 1.9 if is_active else 1.0 + alpha = 0.65 if is_active else 0.18 + elif kind == "arrow": + width = 1.8 if is_active else 1.0 + alpha = 0.95 if is_active else 0.28 + else: + width = 1.8 if is_active else 1.0 + alpha = 0.65 if is_active else 0.18 pen_color = QColor(color) pen_color.setAlphaF(alpha) - item = self.scene().addPath(path, QPen(pen_color, width)) - item.setZValue(z_value) - self.connection_items.append(item) + item.setPen(QPen(pen_color, width)) def _selection_has_assignment(self) -> bool: if self.document is None: @@ -760,10 +1368,18 @@ class LedCanvas(QGraphicsView): min_x, _max_x, _min_y, max_y = self._raw_bounds return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) + def _background_origin_scene_point(self) -> QPointF: + if self._raw_bounds is not None: + min_x, max_x, min_y, max_y = self._raw_bounds + return self._scene_point_from_display_units((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + if self._content_scene_rect is not None: + return self._content_scene_rect.center() + return QPointF(0.0, 0.0) + def _grid_origin_scene_units(self) -> tuple[float, float]: if self.document is None or not self.document.points: return 0.0, 0.0 - points = self.document.points + points = [point for point in self.document.points if not point.manual] or self.document.points min_row = min(point.row for point in points) min_col = min(point.col for point in points) anchor = min(points, key=lambda point: (point.y * self._display_scale, point.x * self._display_scale, point.uid)) @@ -779,6 +1395,15 @@ class LedCanvas(QGraphicsView): min_x, _max_x, _min_y, max_y = self._raw_bounds return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) + def _document_point_from_scene(self, scene_pos: QPointF) -> QPointF: + if self._raw_bounds is None: + return QPointF(scene_pos.x(), scene_pos.y()) + min_x, _max_x, _min_y, max_y = self._raw_bounds + display_x = scene_pos.x() - self._margin + min_x + display_y = max_y - (scene_pos.y() - self._margin) + scale = self._display_scale or 1.0 + return QPointF(display_x / scale, display_y / scale) + @staticmethod def _move_scrollbar(scrollbar: QScrollBar, delta: int) -> None: scrollbar.setValue(scrollbar.value() - delta) diff --git a/icons.py b/icons.py index 320e7d2..113293a 100644 --- a/icons.py +++ b/icons.py @@ -17,6 +17,7 @@ LUCIDE_PATHS = { "folder-open": '', "git-branch": '', "grid-2x2": '', + "image": '', "layers": '', "list-plus": '', "paintbrush": '', diff --git a/main_window.py b/main_window.py index d6122c6..aaa8782 100644 --- a/main_window.py +++ b/main_window.py @@ -1,9 +1,10 @@ from __future__ import annotations from copy import deepcopy from pathlib import Path +import sys from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt -from PySide6.QtGui import QAction, QColor, QKeySequence +from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence from PySide6.QtWidgets import ( QAbstractItemView, QAbstractScrollArea, @@ -33,33 +34,45 @@ from PySide6.QtWidgets import ( ) try: + from .background_dialog import BackgroundDialog from .canvas import LedCanvas from .icons import lucide_icon from .localization import ask_question, localize_message_box, show_critical, show_information, show_warning from .models import LedBlock, LedLine, MappingDocument from .panel_mapping_generator import generate_panel_mapping_from_document - from .settings import AppSettings + from .settings import DEFAULT_SHORTCUTS, AppSettings from .settings_dialog import SettingsDialog from .theme import apply_app_theme, effective_theme from .unfolding import MAX_LINES except ImportError: + from background_dialog import BackgroundDialog from canvas import LedCanvas from icons import lucide_icon from localization import ask_question, localize_message_box, show_critical, show_information, show_warning from models import LedBlock, LedLine, MappingDocument from panel_mapping_generator import generate_panel_mapping_from_document - from settings import AppSettings + from settings import DEFAULT_SHORTCUTS, AppSettings from settings_dialog import SettingsDialog from theme import apply_app_theme, effective_theme from unfolding import MAX_LINES ROOT_DIR = Path(__file__).resolve().parents[1] +APP_DIR = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else ROOT_DIR SORT_ROLE = Qt.UserRole + 1 SIDEBAR_DEFAULT_WIDTH = 260 SIDEBAR_MIN_WIDTH = 180 SIDEBAR_COLLAPSED_WIDTH = 34 MAX_WIDGET_WIDTH = 16777215 +ID_COLUMN = 0 +X_COLUMN = 1 +Y_COLUMN = 2 +SOURCE_COLUMN = 3 +ROW_COLUMN = 4 +COL_COLUMN = 5 +LINE_COLUMN = 6 +BLOCK_COLUMN = 7 +UID_COLUMN = 8 _NATIVE_VIRTUAL_KEY_NAMES = { 0x08: "Backspace", 0x09: "Tab", @@ -129,6 +142,7 @@ class MainWindow(QMainWindow): self._dirty = False self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] + self._pending_background_undo_state: tuple[MappingDocument, int | None, list[int], bool] | None = None self.settings = AppSettings.load() apply_app_theme(self.settings.theme) self.actions: dict[str, QAction] = {} @@ -155,34 +169,41 @@ class MainWindow(QMainWindow): self.canvas.selectionChanged.connect(self._on_selection_changed) self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments) self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point) + self.canvas.addLedRequested.connect(self.add_manual_led) self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) self.canvas.brushPointTouched.connect(self.add_brush_point_to_block) self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke) + self.canvas.backgroundEditStarted.connect(self._on_background_edit_started) + self.canvas.backgroundChanged.connect(self._on_background_changed) + self.canvas.backgroundEditFinished.connect(self._on_background_edit_finished) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Линии и блоки"]) + self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.itemSelectionChanged.connect(self._on_tree_selection) self.tree.customContextMenuRequested.connect(self._show_tree_menu) - self.table = QTableWidget(0, 10) + self.table = QTableWidget(0, 9) self.table.setHorizontalHeaderLabels( - ["UID", "Источник", "Ряд", "Кол.", "Линия", "Блок", "L1", "L2", "L3", "L4"] + ["ID", "X", "Y", "Источник", "Ряд", "Кол.", "Линия", "Блок", "UID"] ) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) self.table.horizontalHeader().setMinimumSectionSize(24) self.table.horizontalHeader().setStretchLastSection(False) - for column, width in enumerate((54, 72, 54, 54, 58, 58, 48, 48, 48, 48)): + for column, width in enumerate((48, 72, 72, 72, 54, 54, 58, 58, 54)): self.table.setColumnWidth(column, width) self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.table.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored) self.table.setMinimumWidth(0) self.table.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) self.table.setAlternatingRowColors(True) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed) self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.horizontalHeader().setSortIndicator(UID_COLUMN, Qt.AscendingOrder) self.table.setSortingEnabled(True) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.itemSelectionChanged.connect(self._select_rows_on_canvas) + self.table.itemChanged.connect(self._on_table_item_changed) self.table.customContextMenuRequested.connect(self._show_table_menu) self.summary_label = QLabel("Файл: - | Точек: 0 | Линий: 0/4 | Блоков: 0 | Назначено: 0") @@ -219,8 +240,8 @@ class MainWindow(QMainWindow): if icon_name is not None: self.action_icon_names[key] = icon_name action.setIcon(lucide_icon(icon_name, self._icon_color())) - if shortcut is not None: - action.setShortcut(self._shortcut_sequence(key, shortcut)) + if shortcut is not None or key in DEFAULT_SHORTCUTS: + action.setShortcut(self._shortcut_sequence(key, shortcut or QKeySequence())) action.setShortcutContext(Qt.ApplicationShortcut) self.shortcut_action_keys.add(key) if tooltip is not None: @@ -243,6 +264,8 @@ class MainWindow(QMainWindow): "Сгенерировать vm_map.h и вспомогательные файлы развертки", ) add_action("add_line", "Новая линия", self.add_line, "file-plus", QKeySequence("Ctrl+L")) + add_led_action = add_action("add_led", "Новый светодиод", self.toggle_add_led_mode, "plus", QKeySequence("N"), "Добавить свободный LED на схему") + add_led_action.setCheckable(True) add_action("add_block", "Блок из выбора", self.add_block_from_selection, "plus", None, "Создать блок в активной линии") add_action("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "layers", QKeySequence("Ctrl+Shift+B"), "Разбить выделение на связанные блоки") add_action("clockwise", "По часовой", self.auto_connect_active_line, "rotate-cw", None, "Пересчитать порядок LED по часовой стрелке") @@ -265,6 +288,27 @@ class MainWindow(QMainWindow): grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм") grid_action.setCheckable(True) grid_action.setChecked(self.settings.show_grid_by_default) + add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки") + background_edit_action = add_action( + "background_edit", + "Редактировать подложку", + self.toggle_background_edit, + "image", + QKeySequence("Ctrl+Shift+I"), + "Перемещать и растягивать подложку на схеме", + ) + background_edit_action.setCheckable(True) + background_visibility_action = add_action( + "background_visible", + "Показать подложку", + self.toggle_background_visibility, + "eye", + None, + "Показать или скрыть подложку", + ) + background_visibility_action.setCheckable(True) + add_action("background_clear", "Удалить подложку", self.clear_background, "trash-2", None, "Удалить изображение подложки") + add_action("background_reset_scale", "Сбросить масштаб подложки", self.reset_background_scale, "archive-restore", None, "Вернуть масштаб подложки к 100%") arrows_action = add_action( "direction_arrows", "Стрелки направления", @@ -275,6 +319,16 @@ class MainWindow(QMainWindow): ) arrows_action.setCheckable(True) arrows_action.setChecked(self.settings.show_direction_arrows_by_default) + led_ids_action = add_action( + "led_ids", + "Отображать id светодиодов", + self.toggle_led_ids, + "eye", + None, + "Показать или скрыть id LED в линии над выбранными точками", + ) + 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")) self._build_menu_bar() self.addAction(self.delete_action) @@ -303,11 +357,20 @@ class MainWindow(QMainWindow): view_menu.addAction(self.actions["fit_view"]) view_menu.addAction(self.actions["grid"]) view_menu.addAction(self.actions["direction_arrows"]) + view_menu.addAction(self.actions["led_ids"]) + view_menu.addSeparator() + background_menu = view_menu.addMenu(lucide_icon("image", self._icon_color()), "Подложка") + background_menu.addAction(self.actions["background"]) + background_menu.addAction(self.actions["background_visible"]) + background_menu.addAction(self.actions["background_edit"]) + background_menu.addAction(self.actions["background_reset_scale"]) + background_menu.addAction(self.actions["background_clear"]) view_menu.addSeparator() view_menu.addAction(self.actions["toggle_sidebar"]) tools_menu = menu_bar.addMenu("Инструменты") tools_menu.addAction(self.actions["add_line"]) + tools_menu.addAction(self.actions["add_led"]) tools_menu.addAction(self.actions["generate_script"]) tools_menu.addSeparator() tools_menu.addAction(self.actions["auto_blocks"]) @@ -423,6 +486,118 @@ class MainWindow(QMainWindow): visible = True if action is None else action.isChecked() self.canvas.set_direction_arrows_visible(visible) + def toggle_led_ids(self) -> None: + action = self.actions.get("led_ids") + visible = True if action is None else action.isChecked() + self.canvas.set_line_index_labels_visible(visible) + + 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) + if enabled: + brush_action = self.actions.get("brush") + if brush_action is not None: + brush_action.setChecked(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: кликните по схеме, чтобы поставить свободный светодиод.") + + def open_background_dialog(self) -> None: + dialog = BackgroundDialog(self.document.background, self) + if dialog.exec() != BackgroundDialog.Accepted: + return + background = dialog.background() + if not background.path: + return + image_path = Path(background.path) + if not image_path.is_absolute() and self.document.path is not None: + image_path = self.document.path.parent / image_path + reader = QImageReader(str(image_path)) + if not reader.canRead(): + show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.") + return + if background.to_payload() == self.document.background.to_payload(): + self.status.setText("Подложка не изменилась.") + return + self._pending_background_undo_state = None + self._push_undo() + self.canvas.set_background(background) + self._dirty = True + self.status.setText("Подложка обновлена.") + self._refresh_ui_state() + + def clear_background(self) -> None: + if not self.document.background.is_enabled(): + return + self._pending_background_undo_state = None + self._push_undo() + self.canvas.clear_background() + self._dirty = True + self.status.setText("Подложка удалена.") + self._refresh_ui_state() + + def reset_background_scale(self) -> None: + if not self.document.background.is_enabled(): + return + if self.document.background.scale_x_percent == 100.0 and self.document.background.scale_y_percent == 100.0: + self.status.setText("Масштаб подложки уже 100%.") + return + self._pending_background_undo_state = None + self._push_undo() + self.canvas.reset_background_scale() + self._dirty = True + self.status.setText("Масштаб подложки сброшен.") + self._refresh_ui_state() + + def toggle_background_visibility(self) -> None: + if not self.document.background.is_enabled(): + return + action = self.actions.get("background_visible") + visible = bool(action is not None and action.isChecked()) + if self.document.background.visible == visible: + return + self._pending_background_undo_state = None + self._push_undo() + self.canvas.set_background_visible(visible) + self._dirty = True + self.status.setText("Подложка показана." if visible else "Подложка скрыта.") + self._refresh_ui_state() + + def toggle_background_edit(self) -> None: + action = self.actions.get("background_edit") + enabled = bool(action is not None and action.isChecked()) + if enabled and not self.document.background.is_enabled(): + if action is not None: + action.setChecked(False) + self.status.setText("Сначала добавьте подложку.") + return + if enabled: + brush_action = self.actions.get("brush") + if brush_action is not None and brush_action.isChecked(): + brush_action.setChecked(False) + 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_brush_enabled(False) + self.canvas.set_background_edit_mode(enabled) + + def _on_background_edit_started(self) -> None: + self._pending_background_undo_state = self._capture_state() + + def _on_background_changed(self) -> None: + if self._pending_background_undo_state is not None: + self._push_undo_state(self._pending_background_undo_state) + self._pending_background_undo_state = None + self._dirty = True + self.status.setText("Подложка изменена.") + self._refresh_ui_state() + + def _on_background_edit_finished(self, changed: bool) -> None: + if not changed: + self._pending_background_undo_state = None + def open_settings(self) -> None: dialog = SettingsDialog(self.settings, self) if dialog.exec() != SettingsDialog.Accepted: @@ -444,6 +619,9 @@ class MainWindow(QMainWindow): arrows_action = self.actions.get("direction_arrows") if arrows_action is not None: arrows_action.setChecked(self.settings.show_direction_arrows_by_default) + led_ids_action = self.actions.get("led_ids") + if led_ids_action is not None: + led_ids_action.setChecked(self.settings.show_led_ids_by_default) def _apply_shortcuts_to_actions(self) -> None: for key in self.shortcut_action_keys: @@ -459,6 +637,7 @@ class MainWindow(QMainWindow): self.canvas.set_theme(effective_theme(self.settings.theme)) self.canvas.set_grid_visible(self.settings.show_grid_by_default) self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default) + self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default) self.canvas.set_brush_radius(self.settings.brush_radius) self.canvas.set_zoom_focus_pull(self.settings.zoom_focus_pull) self.canvas.set_pan_mode(self.settings.pan_mode) @@ -575,7 +754,7 @@ class MainWindow(QMainWindow): path, _ = QFileDialog.getOpenFileName( self, "Открыть карту LED", - str(ROOT_DIR), + str(APP_DIR), "JSON files (*.json);;All files (*.*)", ) if not path: @@ -747,6 +926,17 @@ class MainWindow(QMainWindow): self.status.setText(f"Создана {line.name}. {self._line_summary(line)}") self._refresh_ui_state() + def add_manual_led(self, x: float, y: float) -> None: + self._push_undo() + point = self.document.add_manual_point(x, y) + self._dirty = True + self.canvas.set_document(self.document, reset_view=False) + self.canvas.select_points([point.uid]) + self.refresh_tree() + self._on_selection_changed([point.uid]) + self.status.setText(f"Добавлен свободный LED UID {point.uid}: X={point.x:.3f}, Y={point.y:.3f}.") + self._refresh_ui_state() + def add_block_from_selection(self) -> None: line_id = self._require_active_line() if line_id is None: @@ -861,10 +1051,20 @@ class MainWindow(QMainWindow): if not point_ids: return active_line = self._get_line_or_none(self.active_line_id) + if not any( + self.document.points[point_id].line_id is not None + or self.document.points[point_id].block_id is not None + or self.document.points[point_id].line_index is not None + or self.document.points[point_id].block_index is not None + for point_id in point_ids + if 0 <= point_id < len(self.document.points) + ): + self.status.setText("В выбранных LED нет фиксаций для удаления.") + self._refresh_ui_state() + return self._push_undo() removed = self.document.clear_point_assignments(point_ids) if removed == 0: - self._undo_stack.pop() self.status.setText("В выбранных LED нет фиксаций для удаления.") self._refresh_ui_state() return @@ -1030,6 +1230,10 @@ class MainWindow(QMainWindow): self._brush_stroke_started = False self._brush_append_to_selected = False self.canvas.set_brush_enabled(enabled) + if enabled: + add_led_action = self.actions.get("add_led") + if add_led_action is not None: + add_led_action.setChecked(False) self._sync_brush_preview_exclusions() if enabled: self.status.setText("Кисть включена: обычный мазок создает новый блок, Shift добавляет в выбранный блок.") @@ -1156,6 +1360,22 @@ class MainWindow(QMainWindow): self._refresh_ui_state() def _on_tree_selection(self) -> None: + selected_blocks = self._selected_tree_blocks() + if selected_blocks: + line_ids = {line_id for line_id, _block_id in selected_blocks} + if len(line_ids) == 1: + line_id = selected_blocks[0][0] + point_ids: list[int] = [] + for _line_id, block_id in selected_blocks: + block = self._find_block_or_none(line_id, block_id) + if block is not None: + point_ids.extend(block.point_ids) + self.active_line_id = line_id + self.brush_target = selected_blocks[0] if len(selected_blocks) == 1 else None + self.canvas.set_active_line(self.active_line_id) + self.canvas.select_points(point_ids) + self._sync_brush_preview_exclusions() + return item = self.tree.currentItem() if item is None: return @@ -1184,9 +1404,13 @@ class MainWindow(QMainWindow): def _show_tree_menu(self, pos) -> None: # noqa: ANN001 item = self.tree.itemAt(pos) - if item is not None: + if item is not None and not item.isSelected(): + self.tree.clearSelection() self.tree.setCurrentItem(item) + item.setSelected(True) menu = QMenu(self) + merge_action = menu.addAction("Объединить блоки") + merge_action.setEnabled(self._can_merge_selected_tree_blocks()) label = "Удалить выбранную фиксацию" if item is not None: data = item.data(0, Qt.UserRole) @@ -1201,13 +1425,39 @@ class MainWindow(QMainWindow): fit_action = menu.addAction("Показать все точки") fit_action.setEnabled(bool(self.document.points)) chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) - if chosen == delete_action: + if chosen == merge_action: + self.merge_selected_blocks() + elif chosen == delete_action: self.remove_selected_tree_item() elif chosen == clear_all_action: self.clear_all_assignments() elif chosen == fit_action: self.canvas.fit_to_content() + def merge_selected_blocks(self) -> None: + selected_blocks = self._selected_tree_blocks() + if len(selected_blocks) < 2: + self.status.setText("Выберите два или больше блока для объединения.") + self._refresh_ui_state() + return + line_ids = {line_id for line_id, _block_id in selected_blocks} + if len(line_ids) != 1: + show_information(self, "Разные линии", "Объединять можно только блоки одной линии.") + return + line_id = selected_blocks[0][0] + block_ids = [block_id for _line_id, block_id in selected_blocks] + self._push_undo() + merged = self.document.merge_blocks(line_id, block_ids) + self._dirty = True + self.active_line_id = line_id + self.brush_target = (line_id, merged.id) + self.canvas.select_points(merged.point_ids) + self._after_mapping_change( + f"Объединены блоки: {len(block_ids)} -> {merged.name}, LED: {len(merged.point_ids)}. " + f"{self._unfolding_summary(line_id)}", + selection_already_updated=True, + ) + def _show_table_menu(self, pos) -> None: # noqa: ANN001 item = self.table.itemAt(pos) if item is None: @@ -1240,20 +1490,25 @@ class MainWindow(QMainWindow): for row, point_id in enumerate(point_ids): point = self.document.get_point(point_id) values = [ - point.uid, + "-" if point.line_index is None else point.line_index, + self._format_coordinate(point.x), + self._format_coordinate(point.y), point.source_id, point.row, point.col, "-" if point.line_id is None else point.line_id, "-" if point.block_id is None else point.block_id, + point.uid, ] - values.extend(self._line_index_columns(point.line_id, point.line_index)) for col, value in enumerate(values): item = SortableTableItem(str(value)) item.setData(Qt.UserRole, point.uid) item.setData(SORT_ROLE, self._table_sort_value(value)) + if col in {X_COLUMN, Y_COLUMN} and point.manual: + item.setToolTip("Можно редактировать координату вручную созданного LED.") + else: + item.setFlags(item.flags() & ~Qt.ItemIsEditable) self.table.setItem(row, col, item) - self._update_line_index_column_visibility(point_ids) self.table.setSortingEnabled(sorting_enabled) if sorting_enabled and sort_column >= 0: self.table.sortItems(sort_column, sort_order) @@ -1265,14 +1520,64 @@ class MainWindow(QMainWindow): self._updating_table = False self.selection_label.setText(self._selection_summary(point_ids)) self.status.setText(self._selection_summary(point_ids)) - self._refresh_ui_state() + self._refresh_ui_state(point_ids) + + def _on_table_item_changed(self, item: QTableWidgetItem) -> None: + if self._updating_table or item.column() not in {X_COLUMN, Y_COLUMN}: + return + point_id = item.data(Qt.UserRole) + if not isinstance(point_id, int) or not (0 <= point_id < len(self.document.points)): + return + point = self.document.get_point(point_id) + if not point.manual: + self._restore_table_coordinate_item(item, point) + return + try: + value = float(item.text().replace(",", ".")) + except ValueError: + self._restore_table_coordinate_item(item, point) + self.status.setText("Координата должна быть числом.") + return + old_value = point.x if item.column() == X_COLUMN else point.y + if abs(old_value - value) < 1e-9: + item.setData(SORT_ROLE, self._table_sort_value(value)) + return + self._push_undo() + self.document.update_manual_point_position( + point.uid, + x=value if item.column() == X_COLUMN else None, + y=value if item.column() == Y_COLUMN else None, + ) + self._dirty = True + blocker = QSignalBlocker(self.table) + item.setText(self._format_coordinate(value)) + item.setData(SORT_ROLE, self._table_sort_value(value)) + del blocker + self.canvas.set_document(self.document, reset_view=False) + self.canvas.select_points([point.uid]) + self._on_selection_changed([point.uid]) + self.status.setText(f"LED UID {point.uid}: координаты обновлены.") + + def _restore_table_coordinate_item(self, item: QTableWidgetItem, point) -> None: # noqa: ANN001 + value = point.x if item.column() == X_COLUMN else point.y + blocker = QSignalBlocker(self.table) + item.setText(self._format_coordinate(value)) + item.setData(SORT_ROLE, self._table_sort_value(value)) + del blocker def _select_rows_on_canvas(self) -> None: if self._updating_table: return ids = [] - for item in self.table.selectedItems(): - if item.column() == 0: + selection_model = self.table.selectionModel() + rows = ( + [index.row() for index in selection_model.selectedRows()] + if selection_model is not None + else sorted({item.row() for item in self.table.selectedItems()}) + ) + for row in rows: + item = self.table.item(row, UID_COLUMN) + if item is not None: ids.append(int(item.data(Qt.UserRole))) if ids: self.canvas.select_points(ids) @@ -1337,7 +1642,7 @@ class MainWindow(QMainWindow): rows = sorted({item.row() for item in self.table.selectedItems()}) point_ids: list[int] = [] for row in rows: - item = self.table.item(row, 0) + item = self.table.item(row, UID_COLUMN) if item is not None: point_ids.append(int(item.data(Qt.UserRole))) return point_ids @@ -1352,6 +1657,22 @@ class MainWindow(QMainWindow): return True return False + def _selected_tree_blocks(self) -> list[tuple[int, int]]: + blocks: list[tuple[int, int]] = [] + for item in self.tree.selectedItems(): + data = item.data(0, Qt.UserRole) + if data and data[0] == "block" and self._block_exists(data[1], data[2]): + blocks.append((data[1], data[2])) + order: dict[tuple[int, int], int] = {} + for line in self.document.lines: + for block in line.blocks: + order[(line.id, block.id)] = len(order) + return sorted(set(blocks), key=lambda block_key: order.get(block_key, 10**9)) + + def _can_merge_selected_tree_blocks(self) -> bool: + selected_blocks = self._selected_tree_blocks() + return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1 + def _find_block(self, line_id: int, block_id: int) -> LedBlock: line = self.document.get_line(line_id) for block in line.blocks: @@ -1380,10 +1701,14 @@ class MainWindow(QMainWindow): def _block_exists(self, line_id: int, block_id: int) -> bool: return self._find_block_or_none(line_id, block_id) is not None - def _refresh_ui_state(self) -> None: - point_ids = self.canvas.selected_point_ids() + def _refresh_ui_state(self, point_ids: list[int] | None = None) -> None: + if point_ids is None: + point_ids = self.canvas.selected_point_ids() has_points = bool(self.document.points) has_lines = bool(self.document.lines) + has_background = self.document.background.is_enabled() + has_loaded_background = has_background and self.canvas.background_loaded() + has_visible_background = has_loaded_background and self.document.background.visible has_active_line = self._get_line_or_none(self.active_line_id) is not None has_selection = bool(point_ids) selected_has_assignment = any( @@ -1419,6 +1744,7 @@ class MainWindow(QMainWindow): "undo": bool(self._undo_stack), "redo": bool(self._redo_stack), "add_line": has_points and len(self.document.lines) < MAX_LINES, + "add_led": True, "add_block": has_points and has_active_line, "auto_blocks": has_points and has_active_line and has_selection, "clockwise": has_active_line, @@ -1427,8 +1753,13 @@ class MainWindow(QMainWindow): "delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(), "clear_all": has_lines, "clear_selection": has_selection, - "fit_view": has_points, + "fit_view": has_points or has_visible_background, "grid": True, + "background": True, + "background_visible": has_loaded_background, + "background_edit": has_visible_background, + "background_clear": has_background, + "background_reset_scale": has_loaded_background, "toggle_sidebar": True, } for key, enabled in state.items(): @@ -1443,6 +1774,15 @@ class MainWindow(QMainWindow): brush_action.setChecked(False) self.canvas.set_brush_enabled(False) self._brush_stroke_started = False + background_edit_action = self.actions.get("background_edit") + if background_edit_action is not None and not has_visible_background and background_edit_action.isChecked(): + background_edit_action.setChecked(False) + self.canvas.set_background_edit_mode(False) + background_visible_action = self.actions.get("background_visible") + if background_visible_action is not None: + blocker = QSignalBlocker(background_visible_action) + background_visible_action.setChecked(bool(has_background and self.document.background.visible)) + del blocker self._refresh_block_append_buttons(point_ids) self._refresh_window_title() @@ -1453,11 +1793,16 @@ class MainWindow(QMainWindow): has_selection = bool(selected_set) for (line_id, block_id), button in self.block_append_buttons.items(): block = self._find_block_or_none(line_id, block_id) - block_point_set = set() if block is None else set(block.point_ids) + if block is None: + button.setEnabled(False) + continue + block_point_set = set(block.point_ids) if len(selected_set) <= len(block.point_ids) else None button.setEnabled( - block is not None - and has_selection - and not selected_set.issubset(block_point_set) + has_selection + and ( + block_point_set is None + or not selected_set.issubset(block_point_set) + ) ) def _on_application_focus_changed(self, _old: QWidget | None, _new: QWidget | None) -> None: @@ -1543,7 +1888,10 @@ class MainWindow(QMainWindow): self.canvas.set_brush_excluded_point_ids(excluded_ids) def _push_undo(self) -> None: - self._undo_stack.append(self._capture_state()) + self._push_undo_state(self._capture_state()) + + def _push_undo_state(self, state: tuple[MappingDocument, int | None, list[int], bool]) -> None: + self._undo_stack.append(state) if len(self._undo_stack) > 20: self._undo_stack.pop(0) self._redo_stack.clear() @@ -1653,33 +2001,25 @@ class MainWindow(QMainWindow): return sum(len(block.point_ids) for block in line.blocks) @staticmethod - def _line_index_columns(line_id: int | None, line_index: int | None) -> list[str]: - return [ - "-" if line_id != column or line_index is None else str(line_index) - for column in range(1, MAX_LINES + 1) - ] - - def _update_line_index_column_visibility(self, point_ids: list[int]) -> None: - visible_line_ids = { - point.line_id - for point_id in point_ids - for point in [self.document.get_point(point_id)] - if point.line_id is not None and point.line_index is not None - } - for line_id in range(1, MAX_LINES + 1): - self.table.setColumnHidden(5 + line_id, line_id not in visible_line_ids) - - @staticmethod - def _table_sort_value(value) -> tuple[int, int | str]: # noqa: ANN001 + def _table_sort_value(value) -> tuple[int, int | float | str]: # noqa: ANN001 if value == "-": return 1, 0 - if isinstance(value, int): + if isinstance(value, (int, float)): return 0, value text = str(value) if text.lstrip("-").isdigit(): return 0, int(text) + try: + return 0, float(text.replace(",", ".")) + except ValueError: + pass return 0, text + @staticmethod + def _format_coordinate(value: float) -> str: + text = f"{value:.3f}".rstrip("0").rstrip(".") + return text if text else "0" + def _line_summary(self, line: LedLine) -> str: return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}" diff --git a/models.py b/models.py index 294fc34..81799be 100644 --- a/models.py +++ b/models.py @@ -31,6 +31,7 @@ class LedPoint: line_index: int | None = None block_index: int | None = None coordinate_unit: str = "auto" + manual: bool = False @dataclass @@ -48,12 +49,62 @@ class LedLine: blocks: list[LedBlock] = field(default_factory=list) +@dataclass +class BackgroundImage: + path: str = "" + offset_x: float = 0.0 + offset_y: float = 0.0 + scale_x_percent: float = 100.0 + scale_y_percent: float = 100.0 + opacity_percent: float = 100.0 + visible: bool = True + + @classmethod + def from_payload(cls, payload) -> "BackgroundImage": # noqa: ANN001 + if not isinstance(payload, dict): + return cls() + scale = _optional_float(payload.get("scale_percent")) + scale_x = _optional_float(payload.get("scale_x_percent")) + scale_y = _optional_float(payload.get("scale_y_percent")) + if scale_x is None: + scale_x = 100.0 if scale is None else scale + if scale_y is None: + scale_y = 100.0 if scale is None else scale + offset_x = _optional_float(payload.get("offset_x")) + offset_y = _optional_float(payload.get("offset_y")) + opacity = _optional_float(payload.get("opacity_percent")) + return cls( + path=str(payload.get("path") or ""), + offset_x=0.0 if offset_x is None else offset_x, + offset_y=0.0 if offset_y is None else offset_y, + scale_x_percent=max(1.0, float(scale_x)), + scale_y_percent=max(1.0, float(scale_y)), + opacity_percent=min(100.0, max(0.0, 100.0 if opacity is None else float(opacity))), + visible=bool(payload.get("visible", True)), + ) + + def to_payload(self) -> dict[str, bool | float | str]: + return { + "path": self.path, + "offset_x": self.offset_x, + "offset_y": self.offset_y, + "scale_x_percent": self.scale_x_percent, + "scale_y_percent": self.scale_y_percent, + "opacity_percent": self.opacity_percent, + "visible": self.visible, + } + + def is_enabled(self) -> bool: + return bool(self.path) + + class MappingDocument: def __init__(self) -> None: self.path: Path | None = None self.collection_name = "circles" self.points: list[LedPoint] = [] self.lines: list[LedLine] = [] + self.background = BackgroundImage() self.unfolding = UnfoldingDescription() self.coordinate_unit = "auto" self._next_line_id = 1 @@ -64,6 +115,7 @@ class MappingDocument: self.collection_name = "circles" self.points.clear() self.lines.clear() + self.background = BackgroundImage() self.unfolding = UnfoldingDescription() self.coordinate_unit = "auto" self._next_line_id = 1 @@ -100,10 +152,12 @@ class MappingDocument: line_index=_optional_int(item.get("line_index")), block_index=_optional_int(item.get("block_index")), coordinate_unit=self.coordinate_unit, + manual=bool(item.get("manual", False)), ) ) self._load_lines(data) + self.background = BackgroundImage.from_payload(data.get("background")) self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding")) self.unfolding.max_lines = MAX_LINES if not self.lines and any(point.line_id is not None for point in self.points): @@ -197,6 +251,7 @@ class MappingDocument: "block_id": point.block_id, "line_index": point.line_index, "block_index": point.block_index, + "manual": point.manual, } ) @@ -204,6 +259,7 @@ class MappingDocument: self.collection_name: point_payload, "unfolding": self.describe_unfolding().to_payload(), "coordinate_unit": self._coordinate_unit_for_save(), + "background": self.background.to_payload(), "lines": [ { "id": line.id, @@ -230,6 +286,33 @@ class MappingDocument: self.lines.append(line) return line + def add_manual_point(self, x: float, y: float) -> LedPoint: + uid = len(self.points) + source_id = max((point.source_id for point in self.points), default=-1) + 1 + row, col = self._grid_cell_for_coordinate(float(x), float(y)) + point = LedPoint( + uid=uid, + source_id=source_id, + x=float(x), + y=float(y), + row=row, + col=col, + coordinate_unit=self._coordinate_unit_for_save(), + manual=True, + ) + self.points.append(point) + return point + + def update_manual_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None: + point = self.get_point(point_id) + if not point.manual: + raise ValueError("Only manually created LED points can be moved.") + if x is not None: + point.x = float(x) + if y is not None: + point.y = float(y) + point.row, point.col = self._grid_cell_for_coordinate(point.x, point.y, exclude_uid=point.uid) + def add_block(self, line_id: int, point_ids: list[int]) -> LedBlock: line = self.get_line(line_id) clean_point_ids = unique_in_order([ @@ -262,6 +345,33 @@ class MappingDocument: self.reindex_all() return len(clean_point_ids) + def merge_blocks(self, line_id: int, block_ids: list[int]) -> LedBlock: + line = self.get_line(line_id) + target_ids = set(block_ids) + if len(target_ids) < 2: + raise ValueError("At least two blocks are required for merging.") + selected_blocks = [block for block in line.blocks if block.id in target_ids] + if len(selected_blocks) != len(target_ids): + missing = target_ids - {block.id for block in selected_blocks} + raise KeyError(f"Unknown block ids: {sorted(missing)}") + merged_block = selected_blocks[0] + merged_block.point_ids = unique_in_order( + point_id + for block in selected_blocks + for point_id in block.point_ids + ) + line.blocks = [ + block + for block in line.blocks + if block.id not in target_ids or block.id == merged_block.id + ] + for point_id in merged_block.point_ids: + point = self.points[point_id] + point.line_id = line.id + point.block_id = merged_block.id + self.reindex_all() + return merged_block + def get_line(self, line_id: int) -> LedLine: for line in self.lines: if line.id == line_id: @@ -365,6 +475,19 @@ class MappingDocument: return "mm" return "m" if coordinate_display_scale(self.points) == 1000.0 else "mm" + def _grid_cell_for_coordinate(self, x: float, y: float, *, exclude_uid: int | None = None) -> tuple[int, int]: + anchors = [point for point in self.points if point.uid != exclude_uid and not point.manual] + if not anchors: + anchors = [point for point in self.points if point.uid != exclude_uid] + if not anchors: + return 0, 0 + scale = coordinate_display_scale(anchors) + step = MIN_NEIGHBOR_DISTANCE_MM / scale + anchor = min(anchors, key=lambda point: (point.y, point.x, point.uid)) + row = anchor.row + round((y - anchor.y) / step) + col = anchor.col + round((x - anchor.x) / step) + return int(row), int(col) + def order_lines_clockwise(self) -> None: self.lines = clockwise_sort(self.lines, self._points_for_line) @@ -625,6 +748,13 @@ def _optional_int(value: Any) -> int | None: return None if value is None else int(value) +def _optional_float(value: Any) -> float | None: + try: + return None if value is None else float(value) + except (TypeError, ValueError): + return None + + def default_color(line_id: int) -> str: colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"] return colors[(line_id - 1) % len(colors)] diff --git a/qa_smoke.py b/qa_smoke.py index b127d32..ed7ea7b 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -2,6 +2,8 @@ from __future__ import annotations import json import importlib +import re +import subprocess import sys import tempfile import time @@ -13,6 +15,7 @@ if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parent)) from app.models import ( + BackgroundImage, LedPoint, MappingDocument, build_route_adjacency, @@ -24,31 +27,40 @@ from app.panel_mapping_generator import generate_panel_mapping_from_document from app.unfolding import MAX_LINES try: from app.canvas import LedCanvas + from app.background_dialog import BackgroundDialog from app.icons import LUCIDE_PATHS from app.localization import localize_dialog_buttons, localize_message_box - from app.main_window import MainWindow, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH + from app.main_window import APP_DIR, MainWindow, ROOT_DIR, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH from app.settings import DEFAULT_SHORTCUTS, AppSettings from app.settings_dialog import SettingsDialog - from app.theme import apply_app_theme, effective_theme - from PySide6.QtCore import QPoint, Qt - from PySide6.QtGui import QColor, QKeySequence + from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet + from PySide6.QtCore import QPoint, QPointF, QRectF, Qt + from PySide6.QtGui import QColor, QImage, QKeySequence from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar except ImportError: LedCanvas = None + BackgroundDialog = None LUCIDE_PATHS = {} localize_dialog_buttons = None localize_message_box = None MainWindow = None + APP_DIR = PROJECT_ROOT + ROOT_DIR = PROJECT_ROOT AppSettings = None DEFAULT_SHORTCUTS = {} SettingsDialog = None apply_app_theme = None + dark_stylesheet = None effective_theme = None + light_stylesheet = None SIDEBAR_COLLAPSED_WIDTH = 34 SIDEBAR_MIN_WIDTH = 180 QPoint = None + QPointF = None + QRectF = None Qt = None QColor = None + QImage = None QKeySequence = None QApplication = None QDialogButtonBox = None @@ -76,6 +88,8 @@ def main() -> None: test_legacy_rebuild_groups_by_line_and_block, test_legacy_line_without_block_id_is_preserved, test_points_without_grid_fields_load, + test_manual_led_save_load_roundtrip, + test_manual_led_uses_grid_coordinates, test_loaded_duplicate_memberships_are_deduplicated, test_empty_blocks_are_preserved, test_block_names_keep_increasing_in_line, @@ -83,6 +97,7 @@ def main() -> None: test_reindex_preserves_block_point_order, test_add_block_preserves_start_point, test_append_points_to_block_extends_existing_block, + test_merge_blocks_preserves_first_block_and_reindexes, test_clockwise_block_from_seed_starts_at_selected_point, test_clockwise_block_from_seed_reverses_counterclockwise_corner, test_clockwise_block_from_seed_uses_direction_priority, @@ -92,10 +107,25 @@ def main() -> None: test_saved_coordinate_unit_is_reused, test_uid_point_id_mode_does_not_fallback_to_source_ids, test_panel_mapping_generation_from_document, + test_background_load_save_roundtrip, + test_background_dialog_preserves_non_uniform_scale, + test_canvas_background_layer_and_geometry, + test_canvas_background_zero_offset_uses_scheme_center, + test_canvas_background_edit_move_and_resize_helpers, + test_canvas_background_large_image_downsamples_and_grid_degrades, + test_main_window_background_reset_noops_at_100, + test_main_window_background_actions, + test_main_window_background_noop_edit_preserves_undo_redo, + test_delete_unassigned_selection_preserves_redo, + test_canvas_background_changes_preserve_camera, test_canvas_uses_grid_svg_coordinates, + test_canvas_shows_selected_line_index_labels, test_canvas_rebuild_can_preserve_user_zoom, test_grid_toggle_adds_grid_items, test_canvas_connects_new_block_to_previous_non_empty_block, + test_canvas_bends_overlapping_connections, + test_canvas_bends_partially_overlapping_connections, + test_canvas_active_line_switch_does_not_redraw_connections, test_canvas_draws_direction_arrows_on_connections, test_canvas_can_toggle_direction_arrows, test_canvas_rebuild_drops_deleted_selection_items, @@ -110,10 +140,14 @@ def main() -> None: test_settings_apply_to_canvas_and_generation, test_settings_dialog_has_requested_categories, test_theme_resolution_and_canvas_apply, + test_menu_disabled_items_are_visually_distinct, test_shortcut_settings_are_editable_and_layout_independent, test_standard_dialog_buttons_are_localized, test_lucide_icon_names_are_declared, - test_table_hides_empty_line_index_columns, + test_table_uses_single_line_index_column, + test_manual_led_coordinates_are_editable_in_table, + test_table_selection_uses_selected_rows, + test_tree_can_select_and_merge_multiple_blocks, test_sidebar_collapses_and_expands, test_sidebar_action_toggle_updates_state_and_constraints, test_sidebar_collapsed_tab_click_and_drag_expand, @@ -130,6 +164,7 @@ def main() -> None: test_main_window_restores_table_state_after_selection_error, test_main_window_restores_tree_state_after_refresh_error, test_focus_change_does_not_run_full_ui_refresh, + test_open_dialog_default_dir_uses_application_dir, test_package_imports_work_from_project_root, test_route_adjacency_large_grid_perf_guard, test_route_prefers_up_left_down_right, @@ -151,6 +186,7 @@ def main() -> None: test_auto_block_continues_through_left_down_diagonal, test_too_close_neighbor_has_lowest_priority, test_meter_like_coordinates_use_34_mm_threshold, + test_pyinstaller_spec_build_ref_rules, ] for test in tests: test() @@ -431,6 +467,43 @@ def test_points_without_grid_fields_load() -> None: assert len(doc.auto_block_preview([0, 1])) == 2 +def test_manual_led_save_load_roundtrip() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "manual_led.json" + path.write_text(json.dumps({"circles": []}), encoding="utf-8") + doc = MappingDocument() + doc.load(path) + point = doc.add_manual_point(12.5, 34.75) + assert point.manual + assert point.line_id is None + assert point.block_id is None + save_path = Path(temp_dir) / "saved.json" + doc.save(save_path) + + loaded = MappingDocument() + loaded.load(save_path) + assert len(loaded.points) == 1 + assert loaded.points[0].manual is True + assert loaded.points[0].x == 12.5 + assert loaded.points[0].y == 34.75 + assert loaded.points[0].line_id is None + assert loaded.points[0].block_id is None + + +def test_manual_led_uses_grid_coordinates() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 34.0, 0.0, 0, 1), + LedPoint(2, 2, 0.0, 34.0, 1, 0), + ] + doc = MappingDocument() + doc.points = points + manual = doc.add_manual_point(68.0, 34.0) + assert (manual.row, manual.col) == (1, 2) + doc.update_manual_point_position(manual.uid, x=34.0, y=68.0) + assert (manual.row, manual.col) == (2, 1) + + def test_loaded_duplicate_memberships_are_deduplicated() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "dupes.json" @@ -561,6 +634,25 @@ def test_append_points_to_block_extends_existing_block() -> None: assert [doc.points[point_id].line_index for point_id in first_block.point_ids] == [0, 1, 2, 3] +def test_merge_blocks_preserves_first_block_and_reindexes() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + first = doc.add_block(line.id, [0, 1]) + second = doc.add_block(line.id, [2, 3]) + third = doc.add_block(line.id, [4]) + + merged = doc.merge_blocks(line.id, [third.id, first.id, second.id]) + + line_after = doc.get_line(line.id) + assert merged.id == first.id + assert [block.id for block in line_after.blocks] == [first.id] + assert line_after.blocks[0].point_ids == [0, 1, 2, 3, 4] + assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4] + assert [doc.points[point_id].block_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4] + + def test_clockwise_block_from_seed_starts_at_selected_point() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "ring.json" @@ -733,6 +825,361 @@ def test_panel_mapping_generation_from_document() -> None: assert ordered_payload["leds"][0]["strip"] == 0 +def test_background_load_save_roundtrip() -> None: + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + payload = json.loads(path.read_text(encoding="utf-8")) + payload["background"] = { + "path": "drawing.png", + "offset_x": 12.5, + "offset_y": -7.25, + "scale_x_percent": 80, + "scale_y_percent": 125, + "opacity_percent": 45, + "visible": False, + } + source = Path(temp_dir) / "map.json" + source.write_text(json.dumps(payload), encoding="utf-8") + doc = MappingDocument() + doc.load(source) + assert doc.background.path == "drawing.png" + assert doc.background.offset_x == 12.5 + assert doc.background.offset_y == -7.25 + assert doc.background.scale_x_percent == 80 + assert doc.background.scale_y_percent == 125 + assert doc.background.opacity_percent == 45 + assert doc.background.visible is False + + saved = Path(temp_dir) / "saved.json" + doc.save(saved) + reloaded = MappingDocument() + reloaded.load(saved) + assert reloaded.background.to_payload() == doc.background.to_payload() + + legacy = Path(temp_dir) / "legacy.json" + payload["background"] = {"path": None, "scale_percent": 70} + legacy.write_text(json.dumps(payload), encoding="utf-8") + reloaded.load(legacy) + assert reloaded.background.path == "" + assert reloaded.background.scale_x_percent == 70 + assert reloaded.background.scale_y_percent == 70 + assert reloaded.background.opacity_percent == 100 + assert reloaded.background.visible is True + payload["background"] = {"path": "x.png", "offset_x": "bad", "offset_y": None} + legacy.write_text(json.dumps(payload), encoding="utf-8") + reloaded.load(legacy) + assert reloaded.background.offset_x == 0 + assert reloaded.background.offset_y == 0 + + +def test_background_dialog_preserves_non_uniform_scale() -> None: + if QApplication is None or BackgroundDialog is None: + return + app = QApplication.instance() or QApplication([]) + dialog = BackgroundDialog(BackgroundImage("drawing.png", 1, 2, 80, 125)) + background = dialog.background() + assert background.scale_x_percent == 80 + assert background.scale_y_percent == 125 + assert background.opacity_percent == 100 + assert background.visible is True + dialog.close() + + +def test_canvas_background_layer_and_geometry() -> None: + if LedCanvas is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#ff0000")) + image.save(str(image_path)) + doc = MappingDocument() + doc.load(path) + doc.background = BackgroundImage(str(image_path), 5.0, 7.0, 200.0, 300.0, 55.0, True) + canvas = LedCanvas() + canvas.set_document(doc) + assert canvas._background_item is not None + assert canvas._background_item.zValue() == -30 + assert canvas._background_item.transformationMode() == Qt.SmoothTransformation + assert abs(canvas._background_item.opacity() - 0.55) < 0.01 + assert canvas._background_item.isVisible() + rect = canvas._background_scene_rect() + assert rect is not None + origin = canvas._background_origin_scene_point() + assert rect.width() == 40.0 + assert rect.height() == 30.0 + assert rect.center().x() == origin.x() + 5.0 + assert rect.center().y() == origin.y() + 7.0 + canvas.close() + + doc.background.visible = False + canvas = LedCanvas() + canvas.set_document(doc) + assert canvas._background_item is not None + assert not canvas._background_item.isVisible() + assert canvas._background_scene_rect() is None + canvas.close() + + relative_doc = MappingDocument() + relative_doc.load(path) + relative_doc.path = Path(temp_dir) / "map.json" + relative_doc.background = BackgroundImage("background.png", 0, 0, 100, 100) + canvas = LedCanvas() + canvas.set_document(relative_doc) + assert canvas.background_loaded() + canvas.close() + + +def test_canvas_background_zero_offset_uses_scheme_center() -> None: + if LedCanvas is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#aa0000")) + image.save(str(image_path)) + doc = MappingDocument() + doc.load(path) + doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) + canvas = LedCanvas() + canvas.set_document(doc) + rect = canvas._background_scene_rect() + origin = canvas._background_origin_scene_point() + assert rect is not None + assert rect.center() == origin + + moved_rect = QRectF(rect).translated(12.0, -8.0) + start = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) + canvas._apply_background_rect(moved_rect, start, rect) + assert doc.background.offset_x == 12.0 + assert doc.background.offset_y == -8.0 + canvas.close() + + +def test_canvas_background_edit_move_and_resize_helpers() -> None: + if LedCanvas is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#00ff00")) + image.save(str(image_path)) + doc = MappingDocument() + doc.load(path) + doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) + canvas = LedCanvas() + canvas.set_document(doc) + canvas.set_background_edit_mode(True) + assert canvas._background_handles + starts = [] + changes = [] + canvas.backgroundEditStarted.connect(lambda: starts.append(True)) + canvas.backgroundChanged.connect(lambda: changes.append(True)) + canvas._background_drag = { + "changed": False, + "start_payload": doc.background.to_payload(), + } + canvas._finish_background_edit() + assert starts == [] + assert changes == [] + start = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) + canvas._apply_background_rect(canvas._background_scene_rect().adjusted(0, 0, 20, 10), start, canvas._background_scene_rect()) + assert doc.background.scale_x_percent == 200.0 + assert doc.background.scale_y_percent == 200.0 + canvas.set_background_edit_mode(False) + assert not canvas._background_handles + canvas.close() + + +def test_canvas_background_large_image_downsamples_and_grid_degrades() -> None: + if LedCanvas is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "large_background.png" + image = QImage(8200, 4, QImage.Format.Format_ARGB32) + image.fill(QColor("#123456")) + image.save(str(image_path)) + doc = MappingDocument() + doc.load(path) + doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) + canvas = LedCanvas() + canvas.set_document(doc) + assert canvas._background_item.pixmap().width() <= 8192 + canvas.set_grid_visible(True) + assert len(canvas.grid_items) <= 802 + canvas.close() + + +def test_main_window_background_reset_noops_at_100() -> None: + if MainWindow is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#abcdef")) + image.save(str(image_path)) + window = MainWindow() + window.document.background = BackgroundImage(str(image_path), 0, 0, 100, 100) + window.canvas.set_document(window.document) + undo_before = len(window._undo_stack) + window.reset_background_scale() + assert len(window._undo_stack) == undo_before + assert not window._dirty + window.document.background.scale_x_percent = 120 + window.reset_background_scale() + assert len(window._undo_stack) == undo_before + 1 + assert window._dirty + window._dirty = False + window.close() + + +def test_main_window_background_actions() -> None: + if MainWindow is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#0000ff")) + image.save(str(image_path)) + window = MainWindow() + assert "background" in window.actions + assert "background_visible" in window.actions + assert "background_edit" in window.actions + assert not window.actions["background_edit"].isEnabled() + window.document.background = BackgroundImage(str(image_path)) + window.canvas.set_document(window.document) + window._refresh_ui_state() + assert window.actions["background_edit"].isEnabled() + assert window.actions["background_visible"].isChecked() + window.actions["background_visible"].setChecked(False) + window.toggle_background_visibility() + assert window.document.background.visible is False + assert not window.canvas._background_item.isVisible() + assert not window.actions["background_edit"].isEnabled() + window.actions["background_visible"].setChecked(True) + window.toggle_background_visibility() + assert window.document.background.visible is True + window.actions["background_edit"].setChecked(True) + window.toggle_background_edit() + assert window.canvas.background_edit_mode() + window.document.background = BackgroundImage("dummy.png") + window.canvas.set_document(window.document) + window._refresh_ui_state() + assert not window.actions["background_edit"].isEnabled() + window._dirty = False + window.close() + + +def test_main_window_background_noop_edit_preserves_undo_redo() -> None: + if MainWindow is None or QApplication is None or QImage is None: + return + app = QApplication.instance() or QApplication([]) + with tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(20, 10, QImage.Format.Format_ARGB32) + image.fill(QColor("#654321")) + image.save(str(image_path)) + window = MainWindow() + window.document.background = BackgroundImage(str(image_path)) + window.canvas.set_document(window.document) + redo_state = window._capture_state() + window._redo_stack.append(redo_state) + undo_before = len(window._undo_stack) + + window._on_background_edit_started() + window._on_background_edit_finished(False) + + assert len(window._undo_stack) == undo_before + assert window._redo_stack == [redo_state] + assert not window._dirty + window.close() + + +def test_delete_unassigned_selection_preserves_redo() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path: + window = MainWindow() + doc = MappingDocument() + doc.load(path) + window.document = doc + window.canvas.set_document(doc) + window.canvas.select_points([0]) + redo_state = window._capture_state() + window._redo_stack.append(redo_state) + undo_before = len(window._undo_stack) + + window.clear_selected_point_assignments() + + assert len(window._undo_stack) == undo_before + assert window._redo_stack == [redo_state] + assert not window._dirty + window.close() + + +def test_canvas_background_changes_preserve_camera() -> None: + if LedCanvas is None or QApplication is None or QImage is None or QPointF is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "background.png" + image = QImage(80, 40, QImage.Format.Format_ARGB32) + image.fill(QColor("#445566")) + image.save(str(image_path)) + doc = MappingDocument() + doc.load(path) + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.scale(2.0, 2.0) + canvas.centerOn(QPointF(70.0, 80.0)) + app.processEvents() + before_transform = canvas.transform() + before_center = canvas.mapToScene(canvas.viewport().rect().center()) + + canvas.set_background(BackgroundImage(str(image_path), 500.0, -400.0, 300.0, 250.0)) + after_center = canvas.mapToScene(canvas.viewport().rect().center()) + assert canvas.transform().m11() == before_transform.m11() + assert canvas.transform().m22() == before_transform.m22() + assert abs(after_center.x() - before_center.x()) < 1.0 + assert abs(after_center.y() - before_center.y()) < 1.0 + + rect = canvas._background_scene_rect() + assert rect is not None + start_background = BackgroundImage( + str(image_path), + canvas.document.background.offset_x, + canvas.document.background.offset_y, + canvas.document.background.scale_x_percent, + canvas.document.background.scale_y_percent, + ) + canvas._apply_background_rect(rect.adjusted(-250.0, -250.0, 300.0, 300.0), start_background, rect) + canvas._apply_background_geometry() + resized_center = canvas.mapToScene(canvas.viewport().rect().center()) + assert canvas.transform().m11() == before_transform.m11() + assert abs(resized_center.x() - before_center.x()) < 1.0 + assert abs(resized_center.y() - before_center.y()) < 1.0 + + canvas.reset_background_scale() + reset_center = canvas.mapToScene(canvas.viewport().rect().center()) + assert canvas.transform().m11() == before_transform.m11() + assert abs(reset_center.x() - before_center.x()) < 1.0 + assert abs(reset_center.y() - before_center.y()) < 1.0 + + canvas.clear_background() + cleared_center = canvas.mapToScene(canvas.viewport().rect().center()) + assert canvas.transform().m11() == before_transform.m11() + assert abs(cleared_center.x() - before_center.x()) < 1.0 + assert abs(cleared_center.y() - before_center.y()) < 1.0 + canvas.close() + + def test_canvas_uses_grid_svg_coordinates() -> None: if LedCanvas is None or QApplication is None: return @@ -751,6 +1198,33 @@ def test_canvas_uses_grid_svg_coordinates() -> None: assert item.pos().y() == (max_y - point.y) + margin +def test_canvas_shows_selected_line_index_labels() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, [0, 1]) + canvas = LedCanvas() + canvas.set_document(doc) + + canvas.select_points([0, 2]) + assert set(canvas.line_index_label_items) == {0} + assert canvas.line_index_label_items[0].text() == "0" + assert canvas.line_index_label_items[0].flags() & QGraphicsItem.ItemIgnoresTransformations + initial_delta = canvas.items_by_id[0].pos().y() - canvas.line_index_label_items[0].pos().y() + canvas.scale(8.0, 8.0) + canvas._position_line_index_labels() + zoomed_delta = canvas.items_by_id[0].pos().y() - canvas.line_index_label_items[0].pos().y() + assert zoomed_delta < initial_delta + + canvas.select_points([]) + assert canvas.line_index_label_items == {} + canvas.close() + + def test_canvas_rebuild_can_preserve_user_zoom() -> None: if LedCanvas is None or QApplication is None: return @@ -823,6 +1297,79 @@ def test_canvas_connects_new_block_to_previous_non_empty_block() -> None: assert bridge_found +def test_canvas_bends_overlapping_connections() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "overlap.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 1]) + doc.add_block(second_line.id, [1, 0]) + + canvas = LedCanvas() + canvas.set_document(doc) + + connection_paths = [item.path() for item in canvas.connection_items if item.zValue() == 1] + assert any(path_has_curve(path) for path in connection_paths) + + +def test_canvas_bends_partially_overlapping_connections() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "partial-overlap.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 0.0, "y": 34.0, "row": 1, "col": 0}, + {"id": 2, "x": 0.0, "y": 68.0, "row": 2, "col": 0}, + {"id": 3, "x": 0.0, "y": 102.0, "row": 3, "col": 0}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 2]) + doc.add_block(second_line.id, [1, 3]) + + canvas = LedCanvas() + canvas.set_document(doc) + + connection_paths = [item.path() for item in canvas.connection_items if item.zValue() == 1] + assert any(path_has_curve(path) for path in connection_paths) + + +def test_canvas_active_line_switch_does_not_redraw_connections() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 1, 2]) + doc.add_block(second_line.id, [3, 4, 5]) + canvas = LedCanvas() + canvas.set_document(doc) + before_ids = [id(item) for item in canvas.connection_items] + + canvas.set_active_line(first_line.id) + canvas.set_active_line(second_line.id) + + assert [id(item) for item in canvas.connection_items] == before_ids + + def test_canvas_draws_direction_arrows_on_connections() -> None: if LedCanvas is None or QApplication is None: return @@ -941,14 +1488,32 @@ def test_classic_menu_contains_expected_actions() -> None: expected = { "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"], "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"], - "Вид": ["Показать всё", "Сетка 34 мм", "Стрелки направления", "Скрыть боковую панель"], - "Инструменты": ["Новая линия", "Сгенерировать скрипт", "Автоблоки", "Кисть"], + "Вид": [ + "Показать всё", + "Сетка 34 мм", + "Стрелки направления", + "Отображать id светодиодов", + "Подложка", + "Скрыть боковую панель", + ], + "Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Кисть"], } for top_action in list(window.menuBar().actions()): menu = top_action.menu() if menu is not None and top_action.text() in expected: menu_texts = [action.text() for action in menu.actions() if not action.isSeparator()] assert menu_texts == expected[top_action.text()], top_action.text() + if top_action.text() == "Вид": + background_action = next(action for action in menu.actions() if action.text() == "Подложка") + background_menu = background_action.menu() + assert background_menu is not None + assert [action.text() for action in background_menu.actions() if not action.isSeparator()] == [ + "Подложка", + "Показать подложку", + "Редактировать подложку", + "Сбросить масштаб подложки", + "Удалить подложку", + ] window.close() @@ -1024,6 +1589,7 @@ def test_main_window_actions_use_lucide_icons() -> None: "settings", "generate_script", "add_line", + "add_led", "add_block", "auto_blocks", "clockwise", @@ -1037,6 +1603,12 @@ def test_main_window_actions_use_lucide_icons() -> None: "fit_view", "grid", "direction_arrows", + "led_ids", + "background", + "background_visible", + "background_edit", + "background_clear", + "background_reset_scale", "toggle_sidebar", } missing = [key for key in expected_icon_actions if window.actions[key].icon().isNull()] @@ -1059,9 +1631,19 @@ def test_main_window_action_shortcuts_are_assigned() -> None: "fit_view": "Ctrl+0", "grid": "G", "direction_arrows": "Ctrl+Shift+A", + "led_ids": "I", + "background": "Ctrl+B", + "background_visible": "Ctrl+Alt+B", + "background_edit": "Ctrl+Shift+I", + "background_reset_scale": "Ctrl+Alt+0", + "background_clear": "Ctrl+Alt+Backspace", "toggle_sidebar": "F9", "add_line": "Ctrl+L", + "add_led": "N", + "add_block": "Ctrl+Shift+L", "auto_blocks": "Ctrl+Shift+B", + "clockwise": "Ctrl+Alt+R", + "rebuild_route": "Ctrl+Shift+R", "brush": "B", } for key, shortcut in expected.items(): @@ -1099,6 +1681,7 @@ def test_settings_apply_to_canvas_and_generation() -> None: with tempfile.TemporaryDirectory() as temp_dir: window.settings.show_grid_by_default = True window.settings.show_direction_arrows_by_default = False + window.settings.show_led_ids_by_default = False window.settings.theme = "dark" window.settings.brush_radius = 31 window.settings.zoom_focus_pull = 12 @@ -1112,8 +1695,10 @@ def test_settings_apply_to_canvas_and_generation() -> None: assert window.actions["grid"].isChecked() assert not window.actions["direction_arrows"].isChecked() + assert not window.actions["led_ids"].isChecked() assert window.canvas._grid_visible is True assert window.canvas.direction_arrows_visible() is False + assert window.canvas.line_index_labels_visible() is False assert window.canvas._brush_radius == 31 assert abs(window.canvas._zoom_focus_pull - 0.12) < 0.001 assert window.canvas._pan_mode == "space" @@ -1157,6 +1742,15 @@ def test_theme_resolution_and_canvas_apply() -> None: window.close() +def test_menu_disabled_items_are_visually_distinct() -> None: + if dark_stylesheet is None or light_stylesheet is None: + return + for stylesheet, disabled_color in ((dark_stylesheet(), "#737373"), (light_stylesheet(), "#94a3b8")): + assert "QMenu::item:disabled" in stylesheet + assert "QMenu::item:selected:disabled" in stylesheet + assert disabled_color in stylesheet + + def test_shortcut_settings_are_editable_and_layout_independent() -> None: if QApplication is None or MainWindow is None or SettingsDialog is None or AppSettings is None: return @@ -1229,6 +1823,7 @@ def test_lucide_icon_names_are_declared() -> None: "folder-open", "git-branch", "grid-2x2", + "image", "layers", "paintbrush", "plus", @@ -1243,11 +1838,22 @@ def test_lucide_icon_names_are_declared() -> None: assert expected_names.issubset(set(LUCIDE_PATHS)) -def test_table_hides_empty_line_index_columns() -> None: +def test_table_uses_single_line_index_column() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() + assert [window.table.horizontalHeaderItem(column).text() for column in range(window.table.columnCount())] == [ + "ID", + "X", + "Y", + "Источник", + "Ряд", + "Кол.", + "Линия", + "Блок", + "UID", + ] with temp_document_path() as path: doc = MappingDocument() doc.load(path) @@ -1259,15 +1865,112 @@ def test_table_hides_empty_line_index_columns() -> None: window._on_selection_changed([0, 1]) app.processEvents() - assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [False, True, True, True] + assert window.table.item(0, 0).text() == "0" + assert window.table.item(1, 0).text() == "1" + assert window.table.item(0, 8).text() == "0" window._on_selection_changed([0, 4]) app.processEvents() - assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [False, False, True, True] + assert [window.table.item(row, 0).text() for row in range(2)] == ["0", "0"] window._on_selection_changed([2, 3]) app.processEvents() - assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [True, True, True, True] + assert [window.table.item(row, 0).text() for row in range(2)] == ["-", "-"] + window.close() + + +def test_manual_led_coordinates_are_editable_in_table() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + manual = doc.add_manual_point(10.0, 20.0) + window.document = doc + window.canvas.set_document(doc) + + window.canvas.select_points([manual.uid]) + app.processEvents() + x_item = window.table.item(0, 1) + y_item = window.table.item(0, 2) + assert x_item.flags() & Qt.ItemIsEditable + assert y_item.flags() & Qt.ItemIsEditable + x_item.setText("15.5") + app.processEvents() + + assert window.document.points[manual.uid].x == 15.5 + assert window.document.points[manual.uid].y == 20.0 + assert window._dirty + assert window.canvas.items_by_id[manual.uid].pos() == window.canvas._to_scene_point(15.5, 20.0) + + window.canvas.select_points([0]) + app.processEvents() + assert not (window.table.item(0, 1).flags() & Qt.ItemIsEditable) + window._dirty = False + window.close() + + +def test_table_selection_uses_selected_rows() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + window.document = doc + window.canvas.set_document(doc) + + window._on_selection_changed([0, 1]) + window.table.clearSelection() + window.table.selectRow(1) + app.processEvents() + assert window.canvas.selected_point_ids() == [1] + window.close() + + +def test_tree_can_select_and_merge_multiple_blocks() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + first = doc.add_block(line.id, [0, 1]) + second = doc.add_block(line.id, [2, 3]) + third = doc.add_block(line.id, [4]) + window.document = doc + window.active_line_id = line.id + window.canvas.set_document(doc) + window.refresh_tree() + + line_item = window.tree.topLevelItem(0) + first_item = line_item.child(0) + second_item = line_item.child(1) + window.tree.setCurrentItem(first_item) + first_item.setSelected(True) + assert window._can_merge_selected_tree_blocks() is False + second_item.setSelected(True) + window._on_tree_selection() + + assert window._can_merge_selected_tree_blocks() is True + assert window.canvas.selected_point_ids() == [0, 1, 2, 3] + assert window.brush_target is None + + window.merge_selected_blocks() + + line_after = window.document.get_line(line.id) + assert len(line_after.blocks) == 2 + assert line_after.blocks[0].id == first.id + assert line_after.blocks[0].point_ids == [0, 1, 2, 3] + assert line_after.blocks[1].id == third.id + assert [window.document.points[point_id].line_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4] + assert window.canvas.selected_point_ids() == [0, 1, 2, 3] + window._dirty = False window.close() @@ -1733,8 +2436,13 @@ def test_focus_change_does_not_run_full_ui_refresh() -> None: window.close() +def test_open_dialog_default_dir_uses_application_dir() -> None: + assert APP_DIR == ROOT_DIR + + def test_package_imports_work_from_project_root() -> None: importlib.import_module("app.main") + importlib.import_module("app.background_dialog") importlib.import_module("app.canvas") importlib.import_module("app.localization") importlib.import_module("app.main_window") @@ -1743,6 +2451,41 @@ def test_package_imports_work_from_project_root() -> None: importlib.import_module("app.theme") +def test_pyinstaller_spec_build_ref_rules() -> None: + spec_path = PROJECT_ROOT / "app" / "LEDMapper.spec" + spec_source = spec_path.read_text(encoding="utf-8") + helper_source = spec_source.split("app_name = f'LEDMapper-{git_build_ref()}'", 1)[0] + helper_source = helper_source.replace("spec_dir = Path(SPECPATH)\n\n\n", "") + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + runner = repo / "runner.py" + runner.write_text( + "from pathlib import Path\n" + f"{helper_source}\n" + "spec_dir = Path(__file__).resolve().parent\n" + "print(git_build_ref())\n", + encoding="utf-8", + ) + subprocess.run(["git", "init"], cwd=repo, check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "config", "user.email", "qa@example.local"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "QA"], cwd=repo, check=True) + (repo / "tracked.txt").write_text("clean\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=repo, check=True, stdout=subprocess.DEVNULL) + commit = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=repo, text=True).strip() + + clean_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip() + assert clean_ref == commit + + subprocess.run(["git", "tag", "v9.9"], cwd=repo, check=True) + tagged_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip() + assert tagged_ref == "v9.9" + + (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8") + dirty_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip() + assert re.fullmatch(r"\d{6}-\d{8}", dirty_ref) + + def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001 for index in range(path.elementCount() - 1): first = path.elementAt(index) @@ -1752,6 +2495,13 @@ def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: return False +def path_has_curve(path) -> bool: # noqa: ANN001 + for index in range(path.elementCount()): + if path.elementAt(index).type != 0: + return True + return False + + def test_route_adjacency_large_grid_perf_guard() -> None: points = [ LedPoint(uid, uid, (uid % 30) * 34.0, (uid // 30) * 34.0, uid // 30, uid % 30) diff --git a/settings.py b/settings.py index da2f4b2..07b27cd 100644 --- a/settings.py +++ b/settings.py @@ -22,19 +22,32 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [ ("fit_view", "Показать всё", "Ctrl+0"), ("grid", "Сетка 34 мм", "G"), ("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"), + ("led_ids", "Отображать id светодиодов", "I"), + ("background", "Подложка", "Ctrl+B"), + ("background_visible", "Показать подложку", "Ctrl+Alt+B"), + ("background_edit", "Редактировать подложку", "Ctrl+Shift+I"), + ("background_reset_scale", "Сбросить масштаб подложки", "Ctrl+Alt+0"), + ("background_clear", "Удалить подложку", "Ctrl+Alt+Backspace"), ("toggle_sidebar", "Скрыть боковую панель", "F9"), ("add_line", "Новая линия", "Ctrl+L"), + ("add_led", "Новый светодиод", "N"), + ("add_block", "Блок из выбора", "Ctrl+Shift+L"), ("auto_blocks", "Автоблоки", "Ctrl+Shift+B"), + ("clockwise", "По часовой", "Ctrl+Alt+R"), + ("rebuild_route", "Пересобрать", "Ctrl+Shift+R"), ("brush", "Кисть", "B"), ] DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS} +SETTINGS_VERSION = 2 @dataclass class AppSettings: + version: int = SETTINGS_VERSION theme: str = "system" show_grid_by_default: bool = False show_direction_arrows_by_default: bool = True + show_led_ids_by_default: bool = True zoom_focus_pull: int = 35 pan_mode: str = "middle_space" instant_space_pan: bool = False @@ -53,9 +66,11 @@ class AppSettings: except (OSError, json.JSONDecodeError): return cls() settings = cls() + saved_version = clamp_int(data.get("version", 1), 1, SETTINGS_VERSION, 1) for key, value in data.items(): if hasattr(settings, key): setattr(settings, key, value) + settings.version = SETTINGS_VERSION settings.zoom_focus_pull = clamp_int(settings.zoom_focus_pull, 0, 100, cls.zoom_focus_pull) settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius) if settings.theme not in {"system", "light", "dark"}: @@ -65,9 +80,13 @@ class AppSettings: settings.instant_space_pan = bool(settings.instant_space_pan) settings.show_grid_by_default = bool(settings.show_grid_by_default) settings.show_direction_arrows_by_default = bool(settings.show_direction_arrows_by_default) + settings.show_led_ids_by_default = bool(settings.show_led_ids_by_default) settings.confirm_partial_generation = bool(settings.confirm_partial_generation) settings.default_output_dir = str(settings.default_output_dir or "") - settings.shortcuts = normalize_shortcuts(settings.shortcuts) + settings.shortcuts = normalize_shortcuts( + settings.shortcuts, + fill_new_defaults=saved_version < SETTINGS_VERSION, + ) return settings def save(self) -> None: @@ -96,13 +115,15 @@ def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa: return max(minimum, min(maximum, number)) -def normalize_shortcuts(value) -> dict[str, str]: # noqa: ANN001 +def normalize_shortcuts(value, *, fill_new_defaults: bool = False) -> dict[str, str]: # noqa: ANN001 source = value if isinstance(value, dict) else {} shortcuts: dict[str, str] = {} for key, default in DEFAULT_SHORTCUTS.items(): raw = source.get(key, default) - if raw is None: + if raw is None or (fill_new_defaults and raw == "" and default): raw = "" + if fill_new_defaults and default: + raw = default sequence = QKeySequence(str(raw)) shortcuts[key] = sequence.toString(QKeySequence.SequenceFormat.PortableText) return shortcuts diff --git a/settings_dialog.py b/settings_dialog.py index b3e3c3f..950dc93 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -48,6 +48,8 @@ class SettingsDialog(QDialog): self.show_grid.setChecked(settings.show_grid_by_default) self.show_arrows = QCheckBox("Показывать стрелки направления при запуске") self.show_arrows.setChecked(settings.show_direction_arrows_by_default) + self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе") + self.show_led_ids.setChecked(settings.show_led_ids_by_default) self.zoom_pull = QSpinBox() self.zoom_pull.setRange(0, 100) self.zoom_pull.setSuffix(" %") @@ -107,6 +109,7 @@ class SettingsDialog(QDialog): theme=str(self.theme.currentData()), show_grid_by_default=self.show_grid.isChecked(), show_direction_arrows_by_default=self.show_arrows.isChecked(), + show_led_ids_by_default=self.show_led_ids.isChecked(), zoom_focus_pull=self.zoom_pull.value(), pan_mode=str(self.pan_mode.currentData()), instant_space_pan=self.instant_space_pan.isChecked(), @@ -122,6 +125,7 @@ class SettingsDialog(QDialog): layout.addRow("Тема приложения", self.theme) layout.addRow(self.show_grid) layout.addRow(self.show_arrows) + layout.addRow(self.show_led_ids) layout.addRow("Притяжение зума к курсору", self.zoom_pull) layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору.")) return page diff --git a/theme.py b/theme.py index 9e7785c..8e71d34 100644 --- a/theme.py +++ b/theme.py @@ -131,6 +131,14 @@ def light_stylesheet() -> str: QMenu::item:selected { background-color: #dbeafe; } + QMenu::item:disabled { + color: #94a3b8; + background-color: transparent; + } + QMenu::item:selected:disabled { + color: #94a3b8; + background-color: transparent; + } QPushButton, QToolButton { background-color: #e5e7eb; color: #111827; @@ -374,6 +382,14 @@ def dark_stylesheet() -> str: QMenu::item:selected { background-color: #3a3a3a; } + QMenu::item:disabled { + color: #737373; + background-color: transparent; + } + QMenu::item:selected:disabled { + color: #737373; + background-color: transparent; + } QPushButton, QToolButton { background-color: #2f2f2f; color: #f8fafc;