feat: Improve editor overlays and stability

- Добавлена подложка с видимостью, прозрачностью, масштабом, перемещением и редактированием через отдельное меню.

- Добавлен инструмент создания свободных светодиодов и редактирование X/Y для вручную созданных точек в таблице.

- Упрощена таблица выбранных LED: единый ID, координаты X/Y и UID в конце.

- Добавлены настраиваемые горячие клавиши для всех действий и переключатель отображения id светодиодов.

- Улучшена отрисовка линий: стрелки направления, изгибы при полном и частичном визуальном наложении сегментов.

- Оптимизировано переключение активных линий, выбор точек и поиск наложений соединений для снижения подвисаний.

- Исправлена логика ручных LED: row/col рассчитываются по сетке 34 мм, а ручные точки не сдвигают якорь сетки.

- Обновлена сборка EXE: имя использует тег, короткий хэш или дату/время для dirty-репозитория.

- Расширен smoke-набор до 113 проверок для новых функций и регрессий.
This commit is contained in:
Данил Ярош 2026-05-25 13:39:59 +03:00
parent 9f297b326f
commit d8c95a15e2
10 changed files with 2102 additions and 96 deletions

View File

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

106
background_dialog.py Normal file
View File

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

695
canvas.py
View File

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

View File

@ -17,6 +17,7 @@ LUCIDE_PATHS = {
"folder-open": '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6A2 2 0 0 1 18.46 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.9a2 2 0 0 1 1.69.9L12 6h8a2 2 0 0 1 2 2v2"/><path d="M2 13h4"/>',
"git-branch": '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
"grid-2x2": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/><path d="M12 3v18"/>',
"image": '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
"layers": '<path d="m12.83 2.18 8.34 4.66a1 1 0 0 1 0 1.74l-8.34 4.66a1.7 1.7 0 0 1-1.66 0L2.83 8.58a1 1 0 0 1 0-1.74l8.34-4.66a1.7 1.7 0 0 1 1.66 0Z"/><path d="m22 12.5-9.17 5.12a1.7 1.7 0 0 1-1.66 0L2 12.5"/><path d="m22 17.5-9.17 5.12a1.7 1.7 0 0 1-1.66 0L2 17.5"/>',
"list-plus": '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="M18 9v6"/><path d="M21 12h-6"/>',
"paintbrush": '<path d="m14.62 17.9-1.48 1.48a2.12 2.12 0 1 1-3-3l1.48-1.48"/><path d="m16.1 16.42 5.15-5.15a4.95 4.95 0 0 0-7-7L9.1 9.42"/><path d="m7.5 11 5.5 5.5"/><path d="m9 9-6 6v4h4l6-6"/>',

View File

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

130
models.py
View File

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

View File

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

View File

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

View File

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

View File

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