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;