LED-mapper/canvas.py
Danil Iarosh d8c95a15e2 feat: Improve editor overlays and stability
- Добавлена подложка с видимостью, прозрачностью, масштабом, перемещением и редактированием через отдельное меню.

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

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

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

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

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

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

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

- Расширен smoke-набор до 113 проверок для новых функций и регрессий.
2026-05-25 13:39:59 +03:00

1410 lines
60 KiB
Python

from __future__ import annotations
import math
from pathlib import Path
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,
)
from shiboken6 import isValid
try:
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
except ImportError:
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
MAX_BACKGROUND_PIXMAP_SIDE = 8192
class LedItem(QGraphicsEllipseItem):
def __init__(self, point: LedPoint, radius: float) -> None:
super().__init__(-radius, -radius, radius * 2, radius * 2)
self.point_id = point.uid
self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True)
self.setToolTip(self._tooltip(point))
self.setZValue(10)
@staticmethod
def _tooltip(point: LedPoint) -> str:
line = "-" if point.line_id is None else str(point.line_id)
block = "-" if point.block_id is None else str(point.block_id)
line_index = "-" if point.line_id is None or point.line_index is None else f"Л{point.line_id}: {point.line_index}"
block_index = "-" if point.block_index is None else str(point.block_index)
return (
f"Источник: {point.source_id}\n"
f"Сетка: ряд {point.row}, колонка {point.col}\n"
f"Линия: {line}, блок: {block}\n"
f"Индекс в линии: {line_index}\n"
f"Индекс в блоке: {block_index}"
)
class LedCanvas(QGraphicsView):
selectionChanged = Signal(list)
deleteSelectionRequested = Signal()
autoBlockFromPointRequested = Signal(int)
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)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
self.document: MappingDocument | None = None
self.active_line_id: int | None = None
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
self._brush_zone_item: QGraphicsEllipseItem | None = None
self._grid_visible = False
self._grid_step = 34.0
self._raw_bounds: tuple[float, float, float, float] | None = None
self._content_scene_rect: QRectF | None = None
self._display_scale = 1.0
self._margin = 40.0
self._theme = "dark"
self._pan_mode = "middle_space"
self._instant_space_pan = False
self._space_pressed = False
self._is_panning = False
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()
self._brush_excluded_point_ids: set[int] = set()
self._last_brush_viewport_pos = None
self._last_selected_ids: set[int] = set()
self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
self.scene().selectionChanged.connect(self._emit_selection)
self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.")
def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None:
self.document = document
self.rebuild(reset_view=reset_view)
def set_active_line(self, line_id: int | None) -> None:
if self.active_line_id == line_id:
return
self.active_line_id = line_id
self.refresh_styles(redraw_connections=False)
def set_grid_visible(self, visible: bool) -> None:
self._grid_visible = visible
self._refresh_grid()
def set_direction_arrows_visible(self, visible: bool) -> None:
if self._direction_arrows_visible == visible:
return
self._direction_arrows_visible = visible
self.refresh_styles(redraw_connections=True)
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):
self.scene().removeItem(self._brush_zone_item)
self._brush_zone_item = None
if self._brush_enabled:
self._ensure_brush_zone()
if self._last_brush_viewport_pos is not None:
self._update_brush_zone(self._last_brush_viewport_pos)
def set_zoom_focus_pull(self, percent: int) -> None:
self._zoom_focus_pull = max(0.0, min(1.0, percent / 100.0))
def set_pan_mode(self, mode: str) -> None:
self._pan_mode = mode if mode in {"middle", "space", "middle_space"} else "middle_space"
if self._pan_mode == "middle" and self._space_pressed:
self._space_pressed = False
self._apply_default_cursor()
def set_instant_space_pan(self, enabled: bool) -> None:
self._instant_space_pan = enabled
if not enabled and self._is_panning and self._space_pressed:
self._finish_pan()
def set_theme(self, theme: str) -> None:
next_theme = "dark" if theme == "dark" else "light"
if self._theme == next_theme:
return
self._theme = next_theme
background = "#2b2b2b" if self._theme == "dark" else "#ffffff"
self.scene().setBackgroundBrush(QBrush(QColor(background)))
self._refresh_grid()
self.refresh_styles(redraw_connections=True)
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)
if enabled:
self._ensure_brush_zone()
else:
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())
scene = self.scene()
scene.blockSignals(True)
self.setUpdatesEnabled(False)
try:
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()
self._last_selected_ids.clear()
scene.clear()
finally:
scene.blockSignals(False)
self.setUpdatesEnabled(True)
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)
xs = [point.x * self._display_scale for point in self.document.points]
ys = [point.y * self._display_scale for point in self.document.points]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
self._raw_bounds = (min_x, max_x, min_y, max_y)
self._margin = max(self._point_radius * 4, 40)
for point in self.document.points:
item = LedItem(point, self._point_radius)
item.setPos(self._to_scene_point(point.x, point.y))
self.items_by_id[point.uid] = item
self.scene().addItem(item)
self._content_scene_rect = QRectF(
0,
0,
max(max_x - min_x, 1.0) + self._margin * 2,
max(max_y - min_y, 1.0) + self._margin * 2,
)
self._refresh_background()
self._update_scene_rect()
self._refresh_grid()
self.refresh_styles()
if reset_view:
self.fit_to_content()
else:
self.setTransform(old_transform)
self.centerOn(old_center)
def refresh_styles(self, redraw_connections: bool = True) -> None:
if self.document is None:
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)
if item is None or not isValid(item):
continue
color = QColor("#94a3b8")
if point.line_id in line_by_id:
color = QColor(line_by_id[point.line_id].color)
if self.active_line_id is not None and point.line_id != self.active_line_id:
color.setAlphaF(0.45)
item.setBrush(color)
is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id
try:
pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff")
item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2))
item.setToolTip(LedItem._tooltip(point))
except RuntimeError:
continue
self._last_selected_ids = {
item.point_id
for item in self.scene().selectedItems()
if isinstance(item, LedItem) and isValid(item)
}
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(
item.point_id
for item in self.scene().selectedItems()
if isinstance(item, LedItem) and isValid(item)
)
def select_points(self, point_ids: list[int]) -> None:
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 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
if item.isSelected() != should_select:
item.setSelected(should_select)
finally:
self.setUpdatesEnabled(True)
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:
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
old_anchor = self.transformationAnchor()
self.setTransformationAnchor(QGraphicsView.NoAnchor)
focus_scene_pos = self.mapToScene(viewport_pos)
current_center = self.mapToScene(self.viewport().rect().center())
self.scale(factor, factor)
self._ensure_zoom_padding()
pull_strength = self._zoom_focus_pull
target_center = QPointF(
current_center.x() + (focus_scene_pos.x() - current_center.x()) * pull_strength,
current_center.y() + (focus_scene_pos.y() - current_center.y()) * pull_strength,
)
self.centerOn(target_center)
self.setTransformationAnchor(old_anchor)
def _ensure_zoom_padding(self) -> None:
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(base_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y))
def keyPressEvent(self, event) -> None: # noqa: ANN001
if event.key() == Qt.Key_Delete:
self.deleteSelectionRequested.emit()
event.accept()
return
if event.key() == Qt.Key_Space and not event.isAutoRepeat() and self._can_pan_with_space():
self._space_pressed = True
if self._instant_space_pan and self._last_mouse_pos is not None:
self._start_pan(self._last_mouse_pos)
self._apply_default_cursor()
event.accept()
return
super().keyPressEvent(event)
def keyReleaseEvent(self, event) -> None: # noqa: ANN001
if event.key() == Qt.Key_Space and not event.isAutoRepeat():
self._space_pressed = False
if self._is_panning:
self._is_panning = False
self._last_pan_pos = None
self._apply_default_cursor()
event.accept()
return
super().keyReleaseEvent(event)
def mousePressEvent(self, event) -> None: # noqa: ANN001
self._last_mouse_pos = event.pos()
if event.button() == Qt.RightButton:
event.accept()
return
if self._should_start_pan(event.button()):
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()
self.brushStrokeStarted.emit(bool(event.modifiers() & Qt.ShiftModifier))
self._update_brush_zone(event.pos())
self._touch_brush_point(event.pos())
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # noqa: ANN001
previous_mouse_pos = self._last_mouse_pos
self._last_mouse_pos = event.pos()
if (
self._instant_space_pan
and self._space_pressed
and self._can_pan_with_space()
and not self._is_panning
and previous_mouse_pos is not None
):
self._start_pan(previous_mouse_pos)
if self._is_panning and self._last_pan_pos is not None:
delta = event.pos() - self._last_pan_pos
self._last_pan_pos = event.pos()
self._move_scrollbar(self.horizontalScrollBar(), delta.x())
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())
event.accept()
return
if self._brush_enabled:
self._update_brush_zone(event.pos())
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # noqa: ANN001
self._last_mouse_pos = event.pos()
if event.button() == Qt.RightButton:
event.accept()
return
if self._should_finish_pan(event.button()):
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())
self._is_brush_stroke = False
self._clear_brush_state()
self.brushStrokeFinished.emit()
event.accept()
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)
def leaveEvent(self, event) -> None: # noqa: ANN001
self._hide_brush_zone()
self._clear_brush_preview()
super().leaveEvent(event)
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 or self._add_led_enabled:
cursor_shape = Qt.CrossCursor
else:
cursor_shape = Qt.ArrowCursor
self.setCursor(cursor_shape)
self.viewport().setCursor(cursor_shape)
def _can_pan_with_middle(self) -> bool:
return self._pan_mode in {"middle", "middle_space"}
def _can_pan_with_space(self) -> bool:
return self._pan_mode in {"space", "middle_space"}
def _should_start_pan(self, button: Qt.MouseButton) -> bool:
return (button == Qt.MiddleButton and self._can_pan_with_middle()) or (
button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space()
)
def _should_finish_pan(self, button: Qt.MouseButton) -> bool:
return self._is_panning and (
(button == Qt.MiddleButton and self._can_pan_with_middle())
or (button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space())
)
def _start_pan(self, pos) -> None: # noqa: ANN001
self._is_panning = True
self._last_pan_pos = pos
self._apply_default_cursor()
def _finish_pan(self) -> None:
self._is_panning = False
self._last_pan_pos = None
self._apply_default_cursor()
def contextMenuEvent(self, event) -> None: # noqa: ANN001
item = self.itemAt(event.pos())
point_id = item.point_id if isinstance(item, LedItem) and isValid(item) else self._single_selected_point_id()
menu = QMenu(self)
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
delete_action.setEnabled(self._selection_has_assignment())
clear_selection_action = menu.addAction("Очистить выбор")
clear_selection_action.setEnabled(bool(self.selected_point_ids()))
auto_block_action = menu.addAction("Автоматически добавить блок")
auto_block_action.setEnabled(point_id is not None)
fit_action = menu.addAction("Показать все точки")
fit_action.setEnabled(self.document is not None and bool(self.document.points))
chosen = menu.exec(event.globalPos())
if chosen == delete_action:
self.deleteSelectionRequested.emit()
elif chosen == clear_selection_action:
self.select_points([])
elif chosen == auto_block_action and point_id is not None:
self.autoBlockFromPointRequested.emit(point_id)
elif chosen == fit_action:
self.fit_to_content()
def _emit_selection(self) -> None:
selected_ids = {
item.point_id
for item in self.scene().selectedItems()
if isinstance(item, LedItem) and isValid(item)
}
if selected_ids == self._last_selected_ids:
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:
if self.document is None:
return
for point_id in point_ids:
item = self.items_by_id.get(point_id)
if item is None or not isValid(item):
continue
point = self.document.get_point(point_id)
is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id
try:
pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff")
item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2))
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):
self._brush_touched_ids.add(point_id)
self.brushPointTouched.emit(point_id)
self._update_brush_preview(viewport_pos)
def _brush_candidate_ids(self, viewport_pos) -> list[int]: # noqa: ANN001
return [
item.point_id
for item in self.items_by_id.values()
if isValid(item)
and item.point_id not in self._brush_touched_ids
and item.point_id not in self._brush_excluded_point_ids
and self._is_inside_brush_zone(viewport_pos, item)
]
def _is_inside_brush_zone(self, viewport_center, item: LedItem) -> bool: # noqa: ANN001
point_viewport = self.mapFromScene(item.pos())
radius = self._brush_radius + self._point_radius
dx = viewport_center.x() - point_viewport.x()
dy = viewport_center.y() - point_viewport.y()
return dx * dx + dy * dy <= radius * radius
def _ensure_brush_zone(self) -> QGraphicsEllipseItem | None:
if self.scene() is None:
return None
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
return self._brush_zone_item
item = QGraphicsEllipseItem(-self._brush_radius, -self._brush_radius, self._brush_radius * 2, self._brush_radius * 2)
pen = QPen(QColor("#f43f5e"), 1.4)
pen.setCosmetic(True)
item.setPen(pen)
item.setBrush(QBrush(QColor(244, 63, 94, 32)))
item.setZValue(40)
item.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
item.setAcceptedMouseButtons(Qt.NoButton)
item.setVisible(False)
self.scene().addItem(item)
self._brush_zone_item = item
return item
def _update_brush_zone(self, viewport_pos) -> None: # noqa: ANN001
if not self._brush_enabled:
return
item = self._ensure_brush_zone()
if item is None:
return
self._last_brush_viewport_pos = viewport_pos
item.setPos(self.mapToScene(viewport_pos))
item.setVisible(True)
self._update_brush_preview(viewport_pos)
def _hide_brush_zone(self) -> None:
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
self._brush_zone_item.setVisible(False)
def _update_brush_preview(self, viewport_pos) -> None: # noqa: ANN001
if not self._brush_enabled:
self._clear_brush_preview()
return
next_preview_ids = set(self._brush_candidate_ids(viewport_pos))
changed_ids = self._brush_preview_ids ^ next_preview_ids
self._brush_preview_ids = next_preview_ids
self._refresh_brush_preview_styles(changed_ids | self._brush_touched_ids)
def _clear_brush_preview(self) -> None:
changed_ids = self._brush_preview_ids | self._brush_touched_ids
self._brush_preview_ids.clear()
self._refresh_brush_preview_styles(changed_ids)
def _clear_brush_state(self) -> None:
changed_ids = self._brush_preview_ids | self._brush_touched_ids
self._brush_preview_ids.clear()
self._brush_touched_ids.clear()
self._refresh_brush_preview_styles(changed_ids)
def _refresh_brush_preview_styles(self, point_ids: set[int]) -> None:
if self.document is None:
return
line_by_id = {line.id: line for line in self.document.lines}
for point_id in point_ids:
item = self.items_by_id.get(point_id)
if item is None or not isValid(item):
continue
try:
if point_id in self._brush_touched_ids:
item.setBrush(QColor("#f43f5e"))
item.setPen(QPen(QColor("#ffffff"), 2.4))
continue
if point_id in self._brush_preview_ids:
item.setBrush(QColor("#fbbf24"))
item.setPen(QPen(QColor("#111827"), 2.4))
continue
point = self.document.get_point(point_id)
color = QColor("#94a3b8")
if point.line_id in line_by_id:
color = QColor(line_by_id[point.line_id].color)
if self.active_line_id is not None and point.line_id != self.active_line_id:
color.setAlphaF(0.45)
is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id
pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff")
item.setBrush(color)
item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2))
except RuntimeError:
continue
def _clear_connections(self) -> None:
for item in self.connection_items:
if not isValid(item):
continue
self.scene().removeItem(item)
self.connection_items.clear()
def _refresh_grid(self) -> None:
for item in self.grid_items:
if not isValid(item):
continue
self.scene().removeItem(item)
self.grid_items.clear()
if not self._grid_visible or self._raw_bounds is None:
return
if self.document is None or not self.document.points:
return
min_x, max_x, min_y, max_y = self._raw_bounds
origin_x, origin_y = self._grid_origin_scene_units()
left_x = min_x - self._margin
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, 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)
item = self.scene().addLine(top.x(), top.y(), bottom.x(), bottom.y(), pen)
item.setZValue(-20)
self.grid_items.append(item)
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)
item = self.scene().addLine(left.x(), left.y(), right.x(), right.y(), pen)
item.setZValue(-20)
self.grid_items.append(item)
def _draw_line_connections(self, line: LedLine) -> None:
is_active = self.active_line_id is None or line.id == self.active_line_id
inner_width = 2.8 if is_active else 1.2
inner_alpha = 0.9 if is_active else 0.25
bridge_width = 1.9 if is_active else 1.0
bridge_alpha = 0.65 if is_active else 0.18
arrow_width = 1.8 if is_active else 1.0
arrow_alpha = 0.95 if is_active else 0.28
inner_path = QPainterPath()
inner_arrows = QPainterPath()
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:]):
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, 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,
line_id=line.id,
connection_kind="arrow",
)
previous_block_end = None
bridge_path = QPainterPath()
bridge_arrows = QPainterPath()
for block in line.blocks:
if not block.point_ids:
continue
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:
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, 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, 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,
line_id=line.id,
connection_kind="bridge_arrow",
)
def _append_connection(self, path: QPainterPath, start: QPointF, end: QPointF) -> QPointF | None:
path.moveTo(start)
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, 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
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
base_y = mid_y - unit_y * arrow_length
perp_x = -unit_y
perp_y = unit_x
left = QPointF(base_x + perp_x * arrow_width, base_y + perp_y * arrow_width)
right = QPointF(base_x - perp_x * arrow_width, base_y - perp_y * arrow_width)
tip = QPointF(mid_x, mid_y)
path.moveTo(left)
path.lineTo(tip)
path.lineTo(right)
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.setPen(QPen(pen_color, width))
def _selection_has_assignment(self) -> bool:
if self.document is None:
return False
for point_id in self.selected_point_ids():
point = self.document.get_point(point_id)
if point.line_id is not None or point.block_id is not None:
return True
return False
def _single_selected_point_id(self) -> int | None:
point_ids = self.selected_point_ids()
return point_ids[0] if len(point_ids) == 1 else None
def _to_scene_point(self, x: float, y: float) -> QPointF:
if self._raw_bounds is None:
return QPointF(x, y)
x *= self._display_scale
y *= self._display_scale
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 = [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))
anchor_x = anchor.x * self._display_scale
anchor_y = anchor.y * self._display_scale
origin_x = anchor_x - (anchor.col - min_col + 0.5) * self._grid_step
origin_y = anchor_y - (anchor.row - min_row + 0.5) * self._grid_step
return origin_x, origin_y
def _scene_point_from_display_units(self, x: float, y: float) -> QPointF:
if self._raw_bounds is None:
return QPointF(x, y)
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)