Better drawing and added more icons

This commit is contained in:
2026-07-20 13:18:16 +02:00
parent ee859c4311
commit 4ab74f64e2
27 changed files with 4087 additions and 307 deletions

View File

@@ -1,5 +1,6 @@
from PySide6.QtCore import QPointF, QRectF
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsView
from bedit.gui.preferences import application_settings
@@ -22,3 +23,25 @@ class IconCanvasView(QGraphicsView):
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
for y in range(top, int(rect.bottom()) + grid, grid):
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
def _zoom(self, factor: float) -> None:
target = self.transform().m11() * factor
if 0.25 <= target <= 16.0:
self.scale(factor, factor)
def zoom_in(self) -> None:
self._zoom(1.2)
def zoom_out(self) -> None:
self._zoom(1 / 1.2)
def center_icon(self) -> None:
if self.scene() is not None:
self.fitInView(
self.scene().sceneRect().adjusted(-10, -10, 10, 10),
Qt.AspectRatioMode.KeepAspectRatio,
)
def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
self._zoom(1.2 if event.angleDelta().y() > 0 else 1 / 1.2)
event.accept()

View File

@@ -1,13 +1,15 @@
from copy import deepcopy
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF
from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import (
QColorDialog,
QButtonGroup,
QDialog,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
@@ -19,7 +21,7 @@ from bedit.core.model import Component, Icon, Port
from bedit.gui.generated.ui_icon_editor_dialog import Ui_IconEditorDialog
from bedit.gui.generated.ui_shape_options_dialog import Ui_ShapeOptionsDialog
from bedit.gui.graphics.icon_canvas import icon_grid_size
from bedit.gui.graphics.icon_renderer import _pen
from bedit.gui.graphics.icon_renderer import shape_pen
def _icon_grid_size() -> int:
@@ -49,6 +51,21 @@ class ResizeHandle(QGraphicsEllipseItem):
if self.owner.scene()
else QPointF(128, 128)
)
if self.owner.element.get("type") == "line":
minimum = (
self.owner.scene().sceneRect().topLeft() - self.owner.pos()
if self.owner.scene()
else QPointF(-128, -128)
)
value = QPointF(
min(maximum.x(), max(minimum.x(), _snap(value.x()))),
min(maximum.y(), max(minimum.y(), _snap(value.y()))),
)
self.owner.prepareGeometryChange()
self.owner.element["width"] = value.x()
self.owner.element["height"] = value.y()
self.owner.update()
return super().itemChange(change, value)
value = QPointF(
min(maximum.x(), max(_icon_grid_size(), _snap(value.x()))),
min(maximum.y(), max(_icon_grid_size(), _snap(value.y()))),
@@ -82,9 +99,17 @@ class ShapeOptionsDialog(QDialog):
self.ui.cornerRadiusSpin.setValue(float(self.element.get("cornerRadius", 0)))
self.ui.textEdit.setText(str(self.element.get("text", "Text")))
self.ui.fontSizeSpin.setValue(float(self.element.get("fontSize", 12)))
self._show_row(self.ui.fillTypeLabel, self.ui.fillTypeCombo, self.element.get("type") != "line")
self._show_row(self.ui.fillColorLabel, self.ui.fillColorButton, self.element.get("type") != "line")
self._show_row(self.ui.cornerRadiusLabel, self.ui.cornerRadiusSpin, self.element.get("type") == "rectangle")
self._show_row(
self.ui.fillTypeLabel, self.ui.fillTypeCombo, self.element.get("type") != "line"
)
self._show_row(
self.ui.fillColorLabel, self.ui.fillColorButton, self.element.get("type") != "line"
)
self._show_row(
self.ui.cornerRadiusLabel,
self.ui.cornerRadiusSpin,
self.element.get("type") == "rectangle",
)
is_text = self.element.get("type") == "text"
self._show_row(self.ui.textLabel, self.ui.textEdit, is_text)
self._show_row(self.ui.fontSizeLabel, self.ui.fontSizeSpin, is_text)
@@ -99,7 +124,9 @@ class ShapeOptionsDialog(QDialog):
def _choose_color(self, current: str) -> str:
color = QColorDialog.getColor(
QColor(current), self, "Choose colour",
QColor(current),
self,
"Choose colour",
QColorDialog.ColorDialogOption.ShowAlphaChannel,
)
if not color.isValid():
@@ -129,7 +156,9 @@ class ShapeOptionsDialog(QDialog):
self.element["width"] = self.ui.widthSpin.value()
self.element["height"] = self.ui.heightSpin.value()
if self.element.get("type") != "line":
self.element["fill"] = self.fill_color if self.ui.fillTypeCombo.currentText() == "solid" else "none"
self.element["fill"] = (
self.fill_color if self.ui.fillTypeCombo.currentText() == "solid" else "none"
)
if self.element.get("type") == "rectangle":
self.element["cornerRadius"] = self.ui.cornerRadiusSpin.value()
if self.element.get("type") == "text":
@@ -144,21 +173,35 @@ class ShapeItem(QGraphicsObject):
super().__init__()
self.element = element
self.setPos(float(element.get("x", 0)), float(element.get("y", 0)))
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.resize_handle = ResizeHandle(self)
self.resize_handle.setPos(float(element.get("width", 20)), float(element.get("height", 20)))
self.resize_handle.hide()
def boundingRect(self) -> QRectF: # noqa: N802
margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
return QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))).adjusted(-margin, -margin, margin, margin)
return (
QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
.normalized()
.adjusted(-margin, -margin, margin, margin)
)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20)))
painter.setPen(_pen(self.element))
rect = QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
painter.setPen(shape_pen(self.element))
fill = self.element.get("fill", "none")
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
kind = self.element.get("type")
if kind == "rectangle":
radius = float(self.element.get("cornerRadius", 0))
@@ -168,13 +211,17 @@ class ShapeItem(QGraphicsObject):
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()]))
painter.drawPolygon(
QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()])
)
elif kind == "text":
painter.setPen(QColor(self.element.get("color", "#202020")))
font = painter.font()
font.setPointSizeF(float(self.element.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text")))
painter.drawText(
rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text"))
)
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
@@ -182,23 +229,7 @@ class ShapeItem(QGraphicsObject):
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
value = QPointF(
max(
bounds.left(),
min(
bounds.right() - float(self.element.get("width", 20)),
_snap(value.x()),
),
),
max(
bounds.top(),
min(
bounds.bottom() - float(self.element.get("height", 20)),
_snap(value.y()),
),
),
)
value = self._bounded_position(value)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.element["x"], self.element["y"] = value.x(), value.y()
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
@@ -207,10 +238,17 @@ class ShapeItem(QGraphicsObject):
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.setPos(self._bounded_position(self.pos()))
def _bounded_position(self, position: QPointF) -> QPointF:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
self.setPos(
max(bounds.left(), min(bounds.right() - float(self.element.get("width", 20)), _snap(self.pos().x()))),
max(bounds.top(), min(bounds.bottom() - float(self.element.get("height", 20)), _snap(self.pos().y()))),
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
minimum_x, maximum_x = min(0.0, width), max(0.0, width)
minimum_y, maximum_y = min(0.0, height), max(0.0, height)
return QPointF(
max(bounds.left() - minimum_x, min(bounds.right() - maximum_x, _snap(position.x()))),
max(bounds.top() - minimum_y, min(bounds.bottom() - maximum_y, _snap(position.y()))),
)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
@@ -219,11 +257,19 @@ class ShapeItem(QGraphicsObject):
delete = menu.addAction("Delete Shape")
chosen = menu.exec(event.screenPos())
if chosen is options:
dialog = ShapeOptionsDialog(self.element)
options_element = deepcopy(self.element)
width_sign = -1 if float(options_element.get("width", 20)) < 0 else 1
height_sign = -1 if float(options_element.get("height", 20)) < 0 else 1
options_element["width"] = abs(float(options_element.get("width", 20)))
options_element["height"] = abs(float(options_element.get("height", 20)))
dialog = ShapeOptionsDialog(options_element)
if dialog.exec() == dialog.DialogCode.Accepted:
self.prepareGeometryChange()
self.element.clear()
self.element.update(dialog.element)
if self.element.get("type") == "line":
self.element["width"] *= width_sign
self.element["height"] *= height_sign
self.resize_handle.setPos(
float(self.element.get("width", 20)),
float(self.element.get("height", 20)),
@@ -244,7 +290,11 @@ class PortHandle(QGraphicsEllipseItem):
self.setPen(QPen(QColor("#ffffff"), 1.5))
self.setToolTip(f"{direction.title()}: {port.name} (drag to position)")
self.setZValue(10)
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
@@ -266,6 +316,81 @@ class PortHandle(QGraphicsEllipseItem):
)
class IconDrawingScene(QGraphicsScene):
def __init__(self, editor: "IconEditorDialog", rect: QRectF) -> None:
super().__init__(rect, editor)
self.editor = editor
self.mode = "pointer"
self.start: QPointF | None = None
self.preview: QGraphicsPathItem | None = None
def set_mode(self, mode: str) -> None:
self.mode = mode
self._clear_preview()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.mode == "pointer":
super().mousePressEvent(event)
return
point = self._bounded(event.scenePos())
if self.mode == "text":
text, accepted = QInputDialog.getText(self.editor, "Add Text", "Text:", text="Text")
if accepted:
self.editor.create_shape("text", point, point + QPointF(55, 35), text)
event.accept()
return
self.start = point
self.preview = QGraphicsPathItem()
self.preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
self.preview.setZValue(100)
self.addItem(self.preview)
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None or self.preview is None:
super().mouseMoveEvent(event)
return
end = self._bounded(event.scenePos())
path = QPainterPath()
rect = QRectF(self.start, end).normalized()
if self.mode == "line":
path.moveTo(self.start)
path.lineTo(end)
elif self.mode in {"circle", "ellipse"}:
if self.mode == "circle":
side = min(rect.width(), rect.height())
rect.setSize(QSizeF(side, side))
path.addEllipse(rect)
else:
path.addRect(rect)
self.preview.setPath(path)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None:
super().mouseReleaseEvent(event)
return
end = self._bounded(event.scenePos())
start = self.start
self._clear_preview()
if start != end:
self.editor.create_shape(self.mode, start, end)
event.accept()
def _bounded(self, point: QPointF) -> QPointF:
rect = self.sceneRect()
return QPointF(
min(rect.right(), max(rect.left(), _snap(point.x()))),
min(rect.bottom(), max(rect.top(), _snap(point.y()))),
)
def _clear_preview(self) -> None:
if self.preview is not None:
self.removeItem(self.preview)
self.preview = None
self.start = None
class IconEditorDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
@@ -275,6 +400,10 @@ class IconEditorDialog(QDialog):
self.icon = Icon.from_dict(component.icon.to_dict())
self.inputs = deepcopy(component.inputs)
self.outputs = deepcopy(component.outputs)
self.tool_group = QButtonGroup(self)
self.tool_group.setExclusive(True)
self.tool_group.addButton(self.ui.pointerButton)
self.ui.pointerButton.clicked.connect(lambda: self.set_draw_mode("pointer"))
for button, kind in (
(self.ui.addRectangleButton, "rectangle"),
(self.ui.addCircleButton, "circle"),
@@ -283,43 +412,70 @@ class IconEditorDialog(QDialog):
(self.ui.addTriangleButton, "triangle"),
(self.ui.addTextButton, "text"),
):
button.clicked.connect(
lambda _checked=False, value=kind: self.add_shape(value)
)
self.tool_group.addButton(button)
button.clicked.connect(lambda _checked=False, value=kind: self.set_draw_mode(value))
self.ui.deleteSelectedButton.clicked.connect(self.delete_selected)
self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self)
self.scene = IconDrawingScene(self, QRectF(0, 0, self.icon.width, self.icon.height))
self.ui.iconView.setScene(self.scene)
self.view = self.ui.iconView
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.ui.zoomInButton.clicked.connect(self.view.zoom_in)
self.ui.zoomOutButton.clicked.connect(self.view.zoom_out)
self.ui.centerButton.clicked.connect(self.view.center_icon)
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
for element in self.icon.elements:
self.scene.addItem(ShapeItem(element))
self._add_ports(self.inputs, "input", 0.0)
self._add_ports(self.outputs, "output", self.icon.width)
self.view.fitInView(self.scene.sceneRect().adjusted(-10, -10, 10, 10), Qt.AspectRatioMode.KeepAspectRatio)
self.view.center_icon()
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
spacing = self.icon.height / (len(ports) + 1)
for index, port in enumerate(ports, 1):
saved = port.properties.get("iconPosition", {})
position = QPointF(float(saved.get("x", default_x)), float(saved.get("y", spacing * index)))
position = QPointF(
float(saved.get("x", default_x)), float(saved.get("y", spacing * index))
)
self.scene.addItem(PortHandle(port, direction, position))
def add_shape(self, kind: str) -> None:
count = len([item for item in self.scene.items() if isinstance(item, ShapeItem)])
x, y = 15 + (count * 5) % 40, 15 + (count * 4) % 25
element = {"type": kind, "x": x, "y": y, "width": 55, "height": 35, "fill": "#dbeafe", "stroke": "#303030", "lineWidth": 1.5, "lineStyle": "solid"}
def set_draw_mode(self, mode: str) -> None:
self.scene.set_mode(mode)
self.view.setDragMode(
self.view.DragMode.RubberBandDrag if mode == "pointer" else self.view.DragMode.NoDrag
)
def create_shape(self, kind: str, start: QPointF, end: QPointF, text: str = "Text") -> None:
left, right = sorted((start.x(), end.x()))
top, bottom = sorted((start.y(), end.y()))
element = {
"type": kind,
"x": left,
"y": top,
"width": max(_icon_grid_size(), right - left),
"height": max(_icon_grid_size(), bottom - top),
"fill": "#dbeafe",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
}
if kind == "circle":
element["width"] = element["height"] = 35
element["width"] = element["height"] = min(element["width"], element["height"])
if kind == "line":
element["x"], element["y"] = start.x(), start.y()
element["width"], element["height"] = end.x() - start.x(), end.y() - start.y()
element["fill"] = "none"
if kind == "rectangle":
element["cornerRadius"] = 0
if kind == "text":
text, accepted = QInputDialog.getText(self, "Add Text", "Text:", text="Text")
if not accepted:
return
element.update({"text": text, "fontSize": 12, "color": "#202020", "fill": "none", "lineStyle": "none"})
element.update(
{
"text": text,
"fontSize": 12,
"color": "#202020",
"fill": "none",
"lineStyle": "none",
}
)
self.icon.elements.append(element)
item = ShapeItem(element)
self.scene.addItem(item)
@@ -332,5 +488,7 @@ class IconEditorDialog(QDialog):
item.element["_deleted"] = True
def accept(self) -> None:
self.icon.elements = [element for element in self.icon.elements if not element.pop("_deleted", False)]
self.icon.elements = [
element for element in self.icon.elements if not element.pop("_deleted", False)
]
super().accept()

View File

@@ -13,14 +13,14 @@ def icon_bounds(icon: Icon) -> QRectF:
rect = QRectF(
float(element.get("x", 0)),
float(element.get("y", 0)),
max(0.0, float(element.get("width", 0))),
max(0.0, float(element.get("height", 0))),
)
float(element.get("width", 0)),
float(element.get("height", 0)),
).normalized()
bounds = rect if bounds.isNull() else bounds.united(rect)
return bounds if not bounds.isNull() else QRectF(32, 32, 64, 64)
def _pen(element: dict) -> QPen:
def shape_pen(element: dict) -> QPen:
styles = {
"solid": Qt.PenStyle.SolidLine,
"dash": Qt.PenStyle.DashLine,
@@ -51,12 +51,16 @@ def paint_icon(
for element in icon.elements:
kind = element.get("type", "rectangle")
rect = QRectF(
float(element.get("x", 0)), float(element.get("y", 0)),
float(element.get("width", 20)), float(element.get("height", 20)),
float(element.get("x", 0)),
float(element.get("y", 0)),
float(element.get("width", 20)),
float(element.get("height", 20)),
)
painter.setPen(_pen(element))
painter.setPen(shape_pen(element))
fill = element.get("fill", "#ffffff")
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
if kind == "rectangle":
radius = float(element.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
@@ -65,7 +69,11 @@ def paint_icon(
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]))
painter.drawPolygon(
QPolygonF(
[QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]
)
)
elif kind == "text":
painter.setPen(QColor(element.get("color", element.get("stroke", "#202020"))))
font = QFont()

View File

@@ -24,15 +24,18 @@ from PySide6.QtWidgets import (
QGraphicsView,
QApplication,
QMenu,
QInputDialog,
QStyleOptionGraphicsItem,
QToolTip,
QWidget,
)
from bedit.core.model import Component, Connection, Endpoint, Port
from bedit.core.model import Annotation, Component, Connection, Endpoint, Port
from bedit.gui.controllers.document import DocumentController
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
from bedit.gui.graphics.icon_renderer import icon_bounds, paint_icon
from bedit.gui.graphics.icon_editor import ShapeOptionsDialog
from bedit.gui.graphics.icon_renderer import shape_pen
from bedit.gui.graphics.connection_styles import ConnectionStyle, connection_style
from bedit.gui.preferences import application_settings
@@ -94,7 +97,9 @@ class ComponentGraphicsItem(QGraphicsObject):
position = port.properties.get("iconPosition", {})
item.setPos(
float(position.get("x", x)) * self.WIDTH / self.component.icon.width,
float(position.get("y", spacing * index)) * self.HEIGHT / self.component.icon.height,
float(position.get("y", spacing * index))
* self.HEIGHT
/ self.component.icon.height,
)
result[port.id] = item
return result
@@ -212,6 +217,9 @@ class InterfaceTerminalItem(QGraphicsObject):
super().mousePressEvent(event)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if self.index < 0:
event.accept()
return
menu = QMenu()
options_action = menu.addAction(f"{self.direction.title()} Options…")
if menu.exec(event.screenPos()) is options_action:
@@ -253,13 +261,24 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.end_direction = QPointF(1, 0)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self._update_pen()
self.setZValue(-1)
self.setZValue(0)
self.setToolTip(name or "Connection")
self.handles: list[WaypointHandle] = []
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
self.scene().clearSelection()
self.setSelected(True)
menu = QMenu()
add_node_action = menu.addAction("Add Node")
menu.addSeparator()
options_action = menu.addAction("Connection Options…")
if menu.exec(event.screenPos()) is options_action:
selected = menu.exec(event.screenPos())
if selected is add_node_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.add_route_node("connection", self.connection_id, event.scenePos())
elif selected is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.connectionOptionsRequested.emit(self.connection_id)
@@ -269,6 +288,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self._update_pen()
self._update_handles()
return result
def _update_pen(self) -> None:
@@ -291,6 +311,24 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.start_direction = points[1] - points[0]
self.end_direction = points[-1] - points[-2]
def set_waypoints(self, points: list[QPointF]) -> None:
for handle in self.handles:
if handle.scene() is not None:
handle.scene().removeItem(handle)
self.handles = [
WaypointHandle("connection", self.connection_id, index, point)
for index, point in enumerate(points)
]
scene = self.scene()
if scene is not None:
for handle in self.handles:
scene.addItem(handle)
self._update_handles()
def _update_handles(self) -> None:
for handle in self.handles:
handle.setVisible(self.isSelected())
@staticmethod
def _arrow(end: QPointF, direction: QPointF, size: float) -> QPolygonF:
length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5)
@@ -306,7 +344,211 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
if self.style.arrow_at_target:
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
if self.style.arrow_at_source:
painter.drawPolygon(self._arrow(self.start, -self.start_direction, self.style.arrow_size))
painter.drawPolygon(
self._arrow(self.start, -self.start_direction, self.style.arrow_size)
)
class WaypointHandle(QGraphicsEllipseItem):
def __init__(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
super().__init__(-5, -5, 10, 10)
self.item_kind, self.item_id, self.index = item_kind, item_id, index
self.setPos(position)
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setZValue(1000)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setCursor(Qt.CursorShape.SizeAllCursor)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
snapped = _snapped(value)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.preview_route_node(self.item_kind, self.item_id, self.index, snapped)
return snapped
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.move_route_node(self.item_kind, self.item_id, self.index, self.pos())
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
delete_action = menu.addAction("Delete Node")
if menu.exec(event.screenPos()) is delete_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.delete_route_node(self.item_kind, self.item_id, self.index)
event.accept()
class AnnotationGraphicsItem(QGraphicsObject):
def __init__(self, annotation: Annotation, controller: DocumentController) -> None:
super().__init__()
self.annotation = annotation
self.annotation_id = annotation.id
self.controller = controller
self.drag_start = QPointF(annotation.x, annotation.y)
self.setPos(annotation.x, annotation.y)
self.setZValue(annotation.layer)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def boundingRect(self) -> QRectF: # noqa: N802
width, height = self.annotation.width, self.annotation.height
return QRectF(min(0, width), min(0, height), abs(width), abs(height)).adjusted(-4, -4, 4, 4)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(0, 0, self.annotation.width, self.annotation.height).normalized()
painter.setPen(shape_pen(self.annotation.properties))
fill = self.annotation.properties.get("fill", "none")
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
if self.annotation.kind == "box":
radius = float(self.annotation.properties.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
else:
painter.setPen(
QColor(
self.annotation.properties.get(
"color", self.annotation.properties.get("stroke", "#202020")
)
)
)
font = painter.font()
font.setPointSizeF(float(self.annotation.properties.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(
rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, self.annotation.text
)
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
painter.drawRect(self.boundingRect())
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
new_pos = _snapped(self.pos())
self.setPos(new_pos)
old = {
"x": self.drag_start.x(),
"y": self.drag_start.y(),
"width": self.annotation.width,
"height": self.annotation.height,
}
new = {**old, "x": new_pos.x(), "y": new_pos.y()}
self.controller.set_annotation_geometry(self.annotation_id, old, new)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
return _snapped(value)
return super().itemChange(change, value)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
scene = self.scene()
if not isinstance(scene, GraphScene):
return
if not self.isSelected():
scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options_action = menu.addAction("Shape Options…")
menu.addSeparator()
actions = {
menu.addAction("Bring Forward"): "forward",
menu.addAction("Send Backward"): "backward",
menu.addAction("Bring to Front"): "front",
menu.addAction("Send to Back"): "back",
}
selected = menu.exec(event.screenPos())
if selected is options_action:
scene.edit_annotation_options(self.annotation_id)
elif selected in actions:
scene.reorder_selected_annotations(actions[selected])
event.accept()
class LineAnnotationGraphicsItem(QGraphicsPathItem):
def __init__(self, annotation: Annotation) -> None:
super().__init__()
self.annotation = annotation
self.annotation_id = annotation.id
self.handles: list[WaypointHandle] = []
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setZValue(annotation.layer)
self._update_pen()
def _update_pen(self) -> None:
pen = shape_pen(self.annotation.properties)
if self.isSelected():
pen.setColor(QColor("#2563eb"))
pen.setWidthF(max(3.0, pen.widthF()))
self.setPen(pen)
def set_waypoints(self, points: list[QPointF], start: QPointF, end: QPointF) -> None:
for handle in self.handles:
if handle.scene() is not None:
handle.scene().removeItem(handle)
self.handles = [WaypointHandle("annotation", self.annotation_id, -1, start)]
self.handles.extend(
WaypointHandle("annotation", self.annotation_id, index, point)
for index, point in enumerate(points)
)
self.handles.append(WaypointHandle("annotation", self.annotation_id, -2, end))
scene = self.scene()
if scene is not None:
for handle in self.handles:
scene.addItem(handle)
for handle in self.handles:
handle.setVisible(self.isSelected())
def itemChange(self, change, value): # noqa: N802
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
for handle in self.handles:
handle.setVisible(self.isSelected())
self._update_pen()
return result
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
self.scene().clearSelection()
self.setSelected(True)
menu = QMenu()
add_action = menu.addAction("Add Node")
options_action = menu.addAction("Shape Options…")
menu.addSeparator()
actions = {
menu.addAction("Bring Forward"): "forward",
menu.addAction("Send Backward"): "backward",
menu.addAction("Bring to Front"): "front",
menu.addAction("Send to Back"): "back",
}
selected = menu.exec(event.screenPos())
scene = self.scene()
if isinstance(scene, GraphScene):
if selected is add_action:
scene.add_route_node("annotation", self.annotation_id, event.scenePos())
elif selected is options_action:
scene.edit_annotation_options(self.annotation_id)
elif selected in actions:
scene.reorder_selected_annotations(actions[selected])
event.accept()
class GraphScene(QGraphicsScene):
@@ -322,17 +564,21 @@ class GraphScene(QGraphicsScene):
self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.annotation_items: dict[str, QGraphicsItem] = {}
self.pending_source: ConnectionPortItem | None = None
self.pending_waypoints: list[QPointF] = []
self.pending_preview: QGraphicsPathItem | None = None
self.interaction_mode = "pointer"
self.connection_routing = "spline"
self.connection_routing = "angled"
self.drawing_start: QPointF | None = None
self.drawing_waypoints: list[QPointF] = []
self.setSceneRect(-2000, -2000, 4000, 4000)
controller.documentReset.connect(self.rebuild)
controller.activeGraphChanged.connect(self.rebuild)
controller.componentMoved.connect(self.set_component_position)
controller.componentRotated.connect(self.set_component_rotation)
controller.graphItemChanged.connect(self.refresh_graph_item)
self.rebuild()
def rebuild(self) -> None:
@@ -341,6 +587,7 @@ class GraphScene(QGraphicsScene):
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.annotation_items.clear()
self.pending_source = None
self.pending_waypoints.clear()
self.pending_preview = None
@@ -371,6 +618,16 @@ class GraphScene(QGraphicsScene):
self.addItem(item)
self.connection_items[connection.id] = item
self.update_connection(connection.id)
for annotation in owner.graph.annotations.values():
if annotation.kind == "line":
item = LineAnnotationGraphicsItem(annotation)
self.addItem(item)
self.annotation_items[annotation.id] = item
self.update_annotation(annotation.id)
else:
item = AnnotationGraphicsItem(annotation, self.controller)
self.addItem(item)
self.annotation_items[annotation.id] = item
def set_component_position(self, component_id: str, position: QPointF) -> None:
item = self.component_items.get(component_id)
@@ -383,6 +640,52 @@ class GraphScene(QGraphicsScene):
item.setRotation(rotation)
self.update_connections_for_block(component_id)
def refresh_graph_item(self, item_kind: str, item_id: str) -> None:
if item_kind == "connection":
self.update_connection(item_id)
return
annotation = self.controller.active_graph.annotations.get(item_id)
graphics = self.annotation_items.get(item_id)
if annotation is None or graphics is None:
return
graphics.setZValue(annotation.layer)
if isinstance(graphics, LineAnnotationGraphicsItem):
graphics._update_pen()
self.update_annotation(item_id)
elif isinstance(graphics, AnnotationGraphicsItem):
graphics.prepareGeometryChange()
graphics.setPos(annotation.x, annotation.y)
graphics.update()
def edit_annotation_options(self, annotation_id: str) -> None:
annotation = self.controller.active_graph.annotations.get(annotation_id)
if annotation is None:
return
element = {
"type": {"box": "rectangle", "line": "line", "text": "text"}[annotation.kind],
"width": abs(annotation.width),
"height": abs(annotation.height),
"text": annotation.text,
**annotation.properties,
}
dialog = ShapeOptionsDialog(element)
if dialog.exec() != dialog.DialogCode.Accepted:
return
values = annotation.to_dict()
values["size"] = {
"width": dialog.element["width"] * (-1 if annotation.width < 0 else 1),
"height": dialog.element["height"] * (-1 if annotation.height < 0 else 1),
}
values["text"] = str(dialog.element.get("text", annotation.text))
values["properties"].update(
{
key: value
for key, value in dialog.element.items()
if key not in {"type", "x", "y", "width", "height", "text"}
}
)
self.controller.edit_annotation(annotation_id, values)
def update_connections_for_block(self, component_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if component_id in (connection.source.block, connection.target.block):
@@ -418,7 +721,7 @@ class GraphScene(QGraphicsScene):
if source is None or target is None:
return
start, end = source.scenePos(), target.scenePos()
routing = str(connection.properties.get("routing", "spline"))
routing = str(connection.properties.get("routing", "angled"))
waypoints = [
QPointF(float(point["x"]), float(point["y"]))
for point in connection.properties.get("waypoints", [])
@@ -426,6 +729,35 @@ class GraphScene(QGraphicsScene):
]
path, direction_points = self._route_path(start, end, routing, waypoints)
graphics.set_connection_path(path, direction_points)
graphics.set_waypoints(waypoints)
def update_annotation(self, annotation_id: str) -> None:
annotation = self.controller.active_graph.annotations.get(annotation_id)
graphics = self.annotation_items.get(annotation_id)
if annotation is None or not isinstance(graphics, LineAnnotationGraphicsItem):
return
start = QPointF(annotation.x, annotation.y)
end = start + QPointF(annotation.width, annotation.height)
waypoints = [
QPointF(float(p["x"]), float(p["y"]))
for p in annotation.properties.get("waypoints", [])
]
routing = str(annotation.properties.get("routing", "angled"))
path, points = self._route_path(start, end, routing, waypoints)
graphics.setPath(path)
graphics.set_waypoints(waypoints, start, end)
@staticmethod
def _orthogonal_points(start: QPointF, end: QPointF, waypoints: list[QPointF]) -> list[QPointF]:
points = [start]
for waypoint in [*waypoints, end]:
previous = points[-1]
if previous.x() != waypoint.x() and previous.y() != waypoint.y():
elbow = QPointF(waypoint.x(), previous.y())
points.append(elbow)
if waypoint != points[-1]:
points.append(waypoint)
return points
@staticmethod
def _route_path(
@@ -437,16 +769,23 @@ class GraphScene(QGraphicsScene):
waypoints = waypoints or []
path = QPainterPath(start)
if routing == "angled":
points = [start, *waypoints, end]
points = GraphScene._orthogonal_points(start, end, waypoints)
for point in points[1:]:
path.lineTo(point)
return path, points
if routing == "direct":
path.lineTo(end)
return path, [start, end]
if waypoints:
points = [start, *waypoints, end]
for index, point in enumerate(points[1:-1], start=1):
following = points[index + 1]
midpoint = QPointF((point.x() + following.x()) / 2, (point.y() + following.y()) / 2)
path.quadTo(point, midpoint)
path.quadTo(points[-2], end)
return path, points
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
first_control = start + QPointF(distance, 0)
second_control = end - QPointF(distance, 0)
first_control, second_control = start + QPointF(distance, 0), end - QPointF(distance, 0)
path.cubicTo(first_control, second_control, end)
return path, [start, first_control, second_control, end]
@@ -454,12 +793,16 @@ class GraphScene(QGraphicsScene):
self.interaction_mode = mode
if mode != "connect":
self._clear_pending_source()
self._clear_drawing()
def set_connection_routing(self, routing: str) -> None:
self.connection_routing = routing
self._clear_pending_source()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode in {"box", "text", "line"}:
self._drawing_press(event)
return
if self.interaction_mode != "connect":
self._clear_pending_source()
super().mousePressEvent(event)
@@ -483,7 +826,15 @@ class GraphScene(QGraphicsScene):
self.pending_source.endpoint,
item.endpoint,
routing=self.connection_routing,
waypoints=self.pending_waypoints,
waypoints=(
self._completed_angled_waypoints(
self.pending_source.scenePos(),
item.scenePos(),
self.pending_waypoints,
)
if self.connection_routing == "angled"
else self.pending_waypoints
),
)
except ValueError as error:
QToolTip.showText(event.screenPos(), str(error))
@@ -500,8 +851,222 @@ class GraphScene(QGraphicsScene):
self._update_preview(event.scenePos())
event.accept()
return
if self.interaction_mode in {"box", "line"} and self.drawing_start is not None:
self._update_drawing_preview(event.scenePos())
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode == "box" and self.drawing_start is not None:
end = _snapped(event.scenePos())
if end != self.drawing_start:
self.controller.add_annotation("box", self.drawing_start, end)
self._clear_drawing()
event.accept()
return
super().mouseReleaseEvent(event)
def _drawing_press(self, event: QGraphicsSceneMouseEvent) -> None:
if event.button() == Qt.MouseButton.RightButton:
self._clear_drawing()
event.accept()
return
point = _snapped(event.scenePos())
if self.interaction_mode == "text":
text_value, accepted = QInputDialog.getText(None, "Add Text", "Text:")
if accepted and text_value:
self.controller.add_annotation(
"text", point, point + QPointF(160, 48), text=text_value
)
event.accept()
return
if self.drawing_start is None:
self.drawing_start = point
self.drawing_waypoints = []
self._create_preview()
elif self.interaction_mode == "line" and self.connection_routing == "angled":
self.drawing_waypoints.append(point)
self._update_drawing_preview(point)
elif self.interaction_mode == "line":
self.controller.add_annotation(
"line", self.drawing_start, point, routing=self.connection_routing
)
self._clear_drawing()
event.accept()
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode == "line" and self.drawing_start is not None:
end = _snapped(event.scenePos())
waypoints = (
self.drawing_waypoints[:-1]
if self.drawing_waypoints and self.drawing_waypoints[-1] == end
else self.drawing_waypoints
)
completed = self._completed_angled_waypoints(self.drawing_start, end, waypoints)
self.controller.add_annotation(
"line", self.drawing_start, end, routing="angled", waypoints=completed
)
self._clear_drawing()
event.accept()
return
super().mouseDoubleClickEvent(event)
@staticmethod
def _completed_angled_waypoints(
start: QPointF, end: QPointF, waypoints: list[QPointF]
) -> list[QPointF]:
return GraphScene._orthogonal_points(start, end, waypoints)[1:-1]
def _update_drawing_preview(self, cursor: QPointF) -> None:
if self.drawing_start is None or self.pending_preview is None:
return
end = _snapped(cursor)
if self.interaction_mode == "box":
path = QPainterPath()
path.addRect(QRectF(self.drawing_start, end).normalized())
else:
path, _ = self._route_path(
self.drawing_start, end, self.connection_routing, self.drawing_waypoints
)
self.pending_preview.setPath(path)
def _clear_drawing(self) -> None:
if self.pending_preview is not None and self.pending_source is None:
self.removeItem(self.pending_preview)
self.pending_preview = None
self.drawing_start = None
self.drawing_waypoints.clear()
def _route_values(self, item_kind: str, item_id: str):
item = (
self.controller.active_graph.connections
if item_kind == "connection"
else self.controller.active_graph.annotations
).get(item_id)
if item is None:
return None, []
points = [
QPointF(float(p["x"]), float(p["y"])) for p in item.properties.get("waypoints", [])
]
return item, points
def add_route_node(self, item_kind: str, item_id: str, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:
return
snapped = _snapped(position)
if item_kind == "annotation":
start = QPointF(item.x, item.y)
end = QPointF(item.x + item.width, item.y + item.height)
else:
graphics = self.connection_items[item_id]
start, end = graphics.start, graphics.end
anchors = [start, *points, end]
def segment_distance(point: QPointF, first: QPointF, second: QPointF) -> float:
delta = second - first
length_squared = delta.x() ** 2 + delta.y() ** 2
if length_squared == 0:
return (point - first).manhattanLength()
ratio = max(
0.0,
min(
1.0,
((point.x() - first.x()) * delta.x() + (point.y() - first.y()) * delta.y())
/ length_squared,
),
)
nearest = first + delta * ratio
return (point.x() - nearest.x()) ** 2 + (point.y() - nearest.y()) ** 2
insertion = min(
range(len(anchors) - 1),
key=lambda index: segment_distance(snapped, anchors[index], anchors[index + 1]),
)
points.insert(insertion, snapped)
routing = "angled" if item.properties.get("routing") == "direct" else None
self.controller.set_route_waypoints(item_kind, item_id, points, routing)
def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:
return
position = _snapped(position)
if item_kind == "annotation" and index in {-1, -2}:
old = {
"x": item.x,
"y": item.y,
"width": item.width,
"height": item.height,
}
start = position if index == -1 else QPointF(item.x, item.y)
end = position if index == -2 else QPointF(item.x + item.width, item.y + item.height)
new = {
"x": start.x(),
"y": start.y(),
"width": end.x() - start.x(),
"height": end.y() - start.y(),
}
self.controller.set_annotation_geometry(item_id, old, new)
return
if index < len(points):
points[index] = position
self.controller.set_route_waypoints(item_kind, item_id, points)
def preview_route_node(
self, item_kind: str, item_id: str, index: int, position: QPointF
) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:
return
if item_kind == "annotation" and index in {-1, -2}:
graphics = self.annotation_items.get(item_id)
if not isinstance(graphics, LineAnnotationGraphicsItem):
return
start = position if index == -1 else QPointF(item.x, item.y)
end = position if index == -2 else QPointF(item.x + item.width, item.y + item.height)
path, _direction_points = self._route_path(
start,
end,
str(item.properties.get("routing", "angled")),
points,
)
graphics.setPath(path)
return
if index >= len(points):
return
points[index] = position
routing = str(item.properties.get("routing", "angled"))
if item_kind == "connection":
graphics = self.connection_items.get(item_id)
if graphics is None:
return
path, direction_points = self._route_path(graphics.start, graphics.end, routing, points)
graphics.set_connection_path(path, direction_points)
return
graphics = self.annotation_items.get(item_id)
if not isinstance(graphics, LineAnnotationGraphicsItem):
return
start = QPointF(item.x, item.y)
end = QPointF(item.x + item.width, item.y + item.height)
path, _direction_points = self._route_path(start, end, routing, points)
graphics.setPath(path)
def delete_route_node(self, item_kind: str, item_id: str, index: int) -> None:
_item, points = self._route_values(item_kind, item_id)
if 0 <= index < len(points):
points.pop(index)
self.controller.set_route_waypoints(item_kind, item_id, points)
def reorder_selected_annotations(self, operation: str) -> None:
ids = {
item.annotation_id
for item in self.selectedItems()
if isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem))
}
self.controller.reorder_annotations(ids, operation)
def _create_preview(self) -> None:
self.pending_preview = QGraphicsPathItem()
self.pending_preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
@@ -511,9 +1076,10 @@ class GraphScene(QGraphicsScene):
def _update_preview(self, cursor: QPointF) -> None:
if self.pending_source is None or self.pending_preview is None:
return
preview_cursor = _snapped(cursor) if self.connection_routing == "angled" else cursor
path, _points = self._route_path(
self.pending_source.scenePos(),
cursor,
preview_cursor,
self.connection_routing,
self.pending_waypoints,
)
@@ -622,6 +1188,7 @@ class GraphWorkspaceView(QGraphicsView):
connections: set[str] = set()
inputs: set[str] = set()
outputs: set[str] = set()
annotations: set[str] = set()
for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem):
blocks.add(item.component_id)
@@ -629,32 +1196,29 @@ class GraphWorkspaceView(QGraphicsView):
connections.add(item.connection_id)
elif isinstance(item, InterfaceTerminalItem):
(inputs if item.direction == "input" else outputs).add(item.port.id)
elif isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem)):
annotations.add(item.annotation_id)
self.controller.delete_selection(blocks, connections, inputs, outputs)
self.controller.delete_annotations(annotations)
def has_selected_components(self) -> bool:
scene = self.scene()
return bool(
scene
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
scene and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
)
def has_single_selected_component(self) -> bool:
scene = self.scene()
return bool(
scene
and sum(
isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()
)
== 1
and sum(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()) == 1
)
def open_selected_component(self) -> bool:
if self.controller is None or self.scene() is None:
return False
selected = [
item
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
item for item in self.scene().selectedItems() if isinstance(item, ComponentGraphicsItem)
]
if len(selected) != 1:
return False