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