Fixed dragging corners

This commit is contained in:
2026-07-20 13:24:37 +02:00
parent 4ab74f64e2
commit 6a3e9f01d0
4 changed files with 271 additions and 58 deletions

View File

@@ -115,7 +115,8 @@ BEdit document used as a copy source. The built-in example defines A, B, and C.
clicks, use right-angle segments, and snap to the graph snapping grid. clicks, use right-angle segments, and snap to the graph snapping grid.
Right-click cancels an unfinished connection. Right-click cancels an unfinished connection.
- Select a routed connection to reveal its draggable nodes. Right-click a line - Select a routed connection to reveal its draggable nodes. Right-click a line
to add a node, or right-click a node to delete it. to add a node, or right-click a node to delete it. Angled routes persist every
orthogonal corner as a draggable, grid-snapped node.
- Use **Box**, **Line**, and **Text** to add persistent graph annotations. Lines - Use **Box**, **Line**, and **Text** to add persistent graph annotations. Lines
share the Direct, Angled, and Spline routing controls. Annotation context menus share the Direct, Angled, and Spline routing controls. Annotation context menus
provide the same shape styling as the icon editor and can move annotations provide the same shape styling as the icon editor and can move annotations
@@ -129,7 +130,8 @@ BEdit document used as a copy source. The built-in example defines A, B, and C.
icon shape, icon text, fill color, and border color. The same dialog can hide icon shape, icon text, fill color, and border color. The same dialog can hide
that component's contained subtree from the Libraries tree. that component's contained subtree from the Libraries tree.
- The icon editor uses Pointer and click-drag drawing tools. Its toolbar and - The icon editor uses Pointer and click-drag drawing tools. Its toolbar and
mouse wheel provide zoom in, zoom out, and fit-to-canvas controls. mouse wheel provide zoom in, zoom out, and fit-to-canvas controls. Selected
rectangles expose draggable corner and edge resize handles in both editors.
- Double-click a text component to edit its input list, output list, and - Double-click a text component to edit its input list, output list, and
`implementation.source` JSON. `implementation.source` JSON.
- Right-click any graph component under Current Document to add nested graph or - Right-click any graph component under Current Document to add nested graph or

View File

@@ -34,50 +34,37 @@ def _snap(value: float) -> float:
class ResizeHandle(QGraphicsEllipseItem): class ResizeHandle(QGraphicsEllipseItem):
def __init__(self, owner: "ShapeItem") -> None: CURSORS = {
"n": Qt.CursorShape.SizeVerCursor,
"s": Qt.CursorShape.SizeVerCursor,
"e": Qt.CursorShape.SizeHorCursor,
"w": Qt.CursorShape.SizeHorCursor,
"nw": Qt.CursorShape.SizeFDiagCursor,
"se": Qt.CursorShape.SizeFDiagCursor,
"ne": Qt.CursorShape.SizeBDiagCursor,
"sw": Qt.CursorShape.SizeBDiagCursor,
}
def __init__(self, owner: "ShapeItem", role: str = "se") -> None:
super().__init__(-4, -4, 8, 8, owner) super().__init__(-4, -4, 8, 8, owner)
self.owner = owner self.owner = owner
self.role = role
self.setBrush(QColor("#ffffff")) self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5)) self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setCursor(Qt.CursorShape.SizeFDiagCursor) self.setCursor(self.CURSORS[role])
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setZValue(20) self.setZValue(20)
def itemChange(self, change, value): # noqa: N802 def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: self.owner.begin_resize()
maximum = ( event.accept()
self.owner.scene().sceneRect().bottomRight() - self.owner.pos()
if self.owner.scene() def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
else QPointF(128, 128) self.owner.resize_from_handle(self.role, event.scenePos())
) event.accept()
if self.owner.element.get("type") == "line":
minimum = ( def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.scene().sceneRect().topLeft() - self.owner.pos() self.owner.finish_resize()
if self.owner.scene() event.accept()
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()))),
)
if self.owner.element.get("type") == "circle":
side = min(maximum.x(), maximum.y(), max(value.x(), value.y()))
value = QPointF(side, side)
self.owner.prepareGeometryChange()
self.owner.element["width"] = value.x()
self.owner.element["height"] = value.y()
self.owner.update()
return super().itemChange(change, value)
class ShapeOptionsDialog(QDialog): class ShapeOptionsDialog(QDialog):
@@ -178,9 +165,16 @@ class ShapeItem(QGraphicsObject):
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
) )
self.resize_handle = ResizeHandle(self) roles = (
self.resize_handle.setPos(float(element.get("width", 20)), float(element.get("height", 20))) ("n", "ne", "e", "se", "s", "sw", "w", "nw")
self.resize_handle.hide() if element.get("type") == "rectangle"
else ("se",)
)
self.resize_handles = [ResizeHandle(self, role) for role in roles]
self.resize_start: dict | None = None
self._position_resize_handles()
for handle in self.resize_handles:
handle.hide()
def boundingRect(self) -> QRectF: # noqa: N802 def boundingRect(self) -> QRectF: # noqa: N802
margin = max(3.0, float(self.element.get("lineWidth", 1.5))) margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
@@ -233,7 +227,8 @@ class ShapeItem(QGraphicsObject):
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.element["x"], self.element["y"] = value.x(), value.y() self.element["x"], self.element["y"] = value.x(), value.y()
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged: elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self.resize_handle.setVisible(bool(value)) for handle in self.resize_handles:
handle.setVisible(bool(value))
return super().itemChange(change, value) return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
@@ -251,6 +246,74 @@ class ShapeItem(QGraphicsObject):
max(bounds.top() - minimum_y, min(bounds.bottom() - maximum_y, _snap(position.y()))), max(bounds.top() - minimum_y, min(bounds.bottom() - maximum_y, _snap(position.y()))),
) )
def _position_resize_handles(self) -> None:
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
positions = {
"n": QPointF(width / 2, 0),
"ne": QPointF(width, 0),
"e": QPointF(width, height / 2),
"se": QPointF(width, height),
"s": QPointF(width / 2, height),
"sw": QPointF(0, height),
"w": QPointF(0, height / 2),
"nw": QPointF(0, 0),
}
for handle in self.resize_handles:
handle.setPos(positions[handle.role])
def begin_resize(self) -> None:
self.resize_start = {
"left": self.pos().x(),
"top": self.pos().y(),
"right": self.pos().x() + float(self.element.get("width", 20)),
"bottom": self.pos().y() + float(self.element.get("height", 20)),
}
def resize_from_handle(self, role: str, scene_position: QPointF) -> None:
if self.resize_start is None:
self.begin_resize()
if self.element.get("type") == "line":
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
endpoint = QPointF(
min(bounds.right(), max(bounds.left(), _snap(scene_position.x()))),
min(bounds.bottom(), max(bounds.top(), _snap(scene_position.y()))),
)
self.prepareGeometryChange()
self.element["width"] = endpoint.x() - self.pos().x()
self.element["height"] = endpoint.y() - self.pos().y()
self._position_resize_handles()
self.update()
return
edges = dict(self.resize_start)
point = QPointF(_snap(scene_position.x()), _snap(scene_position.y()))
if "w" in role:
edges["left"] = min(point.x(), edges["right"] - _icon_grid_size())
if "e" in role:
edges["right"] = max(point.x(), edges["left"] + _icon_grid_size())
if "n" in role:
edges["top"] = min(point.y(), edges["bottom"] - _icon_grid_size())
if "s" in role:
edges["bottom"] = max(point.y(), edges["top"] + _icon_grid_size())
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
edges["left"] = max(bounds.left(), edges["left"])
edges["top"] = max(bounds.top(), edges["top"])
edges["right"] = min(bounds.right(), edges["right"])
edges["bottom"] = min(bounds.bottom(), edges["bottom"])
if self.element.get("type") == "circle":
side = min(edges["right"] - edges["left"], edges["bottom"] - edges["top"])
edges["right"] = edges["left"] + side
edges["bottom"] = edges["top"] + side
self.prepareGeometryChange()
self.element["width"] = edges["right"] - edges["left"]
self.element["height"] = edges["bottom"] - edges["top"]
self.setPos(edges["left"], edges["top"])
self._position_resize_handles()
self.update()
def finish_resize(self) -> None:
self.resize_start = None
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu() menu = QMenu()
options = menu.addAction("Shape Options…") options = menu.addAction("Shape Options…")
@@ -270,10 +333,7 @@ class ShapeItem(QGraphicsObject):
if self.element.get("type") == "line": if self.element.get("type") == "line":
self.element["width"] *= width_sign self.element["width"] *= width_sign
self.element["height"] *= height_sign self.element["height"] *= height_sign
self.resize_handle.setPos( self._position_resize_handles()
float(self.element.get("width", 20)),
float(self.element.get("height", 20)),
)
self.update() self.update()
elif chosen is delete and self.scene() is not None: elif chosen is delete and self.scene() is not None:
self.scene().removeItem(self) self.scene().removeItem(self)

View File

@@ -217,9 +217,6 @@ class InterfaceTerminalItem(QGraphicsObject):
super().mousePressEvent(event) super().mousePressEvent(event)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if self.index < 0:
event.accept()
return
menu = QMenu() menu = QMenu()
options_action = menu.addAction(f"{self.direction.title()} Options…") options_action = menu.addAction(f"{self.direction.title()} Options…")
if menu.exec(event.screenPos()) is options_action: if menu.exec(event.screenPos()) is options_action:
@@ -362,6 +359,20 @@ class WaypointHandle(QGraphicsEllipseItem):
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
) )
self.setCursor(Qt.CursorShape.SizeAllCursor) self.setCursor(Qt.CursorShape.SizeAllCursor)
self.drag_offset = QPointF()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
scene = self.scene()
if isinstance(scene, GraphScene):
owner = scene.route_graphics_item(self.item_kind, self.item_id)
if owner is not None:
owner.setSelected(True)
self.drag_offset = self.pos() - event.scenePos()
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.setPos(_snapped(event.scenePos() + self.drag_offset))
event.accept()
def itemChange(self, change, value): # noqa: N802 def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
@@ -373,12 +384,15 @@ class WaypointHandle(QGraphicsEllipseItem):
return super().itemChange(change, value) return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
scene = self.scene() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.move_route_node(self.item_kind, self.item_id, self.index, self.pos()) scene.move_route_node(self.item_kind, self.item_id, self.index, self.pos())
event.accept()
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if self.index < 0:
event.accept()
return
menu = QMenu() menu = QMenu()
delete_action = menu.addAction("Delete Node") delete_action = menu.addAction("Delete Node")
if menu.exec(event.screenPos()) is delete_action: if menu.exec(event.screenPos()) is delete_action:
@@ -388,6 +402,40 @@ class WaypointHandle(QGraphicsEllipseItem):
event.accept() event.accept()
class AnnotationResizeHandle(QGraphicsEllipseItem):
CURSORS = {
"n": Qt.CursorShape.SizeVerCursor,
"s": Qt.CursorShape.SizeVerCursor,
"e": Qt.CursorShape.SizeHorCursor,
"w": Qt.CursorShape.SizeHorCursor,
"nw": Qt.CursorShape.SizeFDiagCursor,
"se": Qt.CursorShape.SizeFDiagCursor,
"ne": Qt.CursorShape.SizeBDiagCursor,
"sw": Qt.CursorShape.SizeBDiagCursor,
}
def __init__(self, owner: "AnnotationGraphicsItem", role: str) -> None:
super().__init__(-4, -4, 8, 8, owner)
self.owner, self.role = owner, role
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setCursor(self.CURSORS[role])
self.setZValue(1000)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.setSelected(True)
self.owner.begin_resize()
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.resize_from_handle(self.role, event.scenePos())
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.finish_resize()
event.accept()
class AnnotationGraphicsItem(QGraphicsObject): class AnnotationGraphicsItem(QGraphicsObject):
def __init__(self, annotation: Annotation, controller: DocumentController) -> None: def __init__(self, annotation: Annotation, controller: DocumentController) -> None:
super().__init__() super().__init__()
@@ -395,6 +443,7 @@ class AnnotationGraphicsItem(QGraphicsObject):
self.annotation_id = annotation.id self.annotation_id = annotation.id
self.controller = controller self.controller = controller
self.drag_start = QPointF(annotation.x, annotation.y) self.drag_start = QPointF(annotation.x, annotation.y)
self.resize_start: dict[str, float] | None = None
self.setPos(annotation.x, annotation.y) self.setPos(annotation.x, annotation.y)
self.setZValue(annotation.layer) self.setZValue(annotation.layer)
self.setFlags( self.setFlags(
@@ -402,6 +451,15 @@ class AnnotationGraphicsItem(QGraphicsObject):
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
) )
roles = ("n", "ne", "e", "se", "s", "sw", "w", "nw")
self.resize_handles = (
[AnnotationResizeHandle(self, role) for role in roles]
if annotation.kind == "box"
else []
)
self._position_resize_handles()
for handle in self.resize_handles:
handle.hide()
def boundingRect(self) -> QRectF: # noqa: N802 def boundingRect(self) -> QRectF: # noqa: N802
width, height = self.annotation.width, self.annotation.height width, height = self.annotation.width, self.annotation.height
@@ -457,8 +515,75 @@ class AnnotationGraphicsItem(QGraphicsObject):
def itemChange(self, change, value): # noqa: N802 def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
return _snapped(value) return _snapped(value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
for handle in self.resize_handles:
handle.setVisible(bool(value))
return super().itemChange(change, value) return super().itemChange(change, value)
def _position_resize_handles(self) -> None:
width, height = self.annotation.width, self.annotation.height
positions = {
"n": QPointF(width / 2, 0),
"ne": QPointF(width, 0),
"e": QPointF(width, height / 2),
"se": QPointF(width, height),
"s": QPointF(width / 2, height),
"sw": QPointF(0, height),
"w": QPointF(0, height / 2),
"nw": QPointF(0, 0),
}
for handle in self.resize_handles:
handle.setPos(positions[handle.role])
def begin_resize(self) -> None:
self.resize_start = {
"x": self.annotation.x,
"y": self.annotation.y,
"width": self.annotation.width,
"height": self.annotation.height,
}
def resize_from_handle(self, role: str, scene_position: QPointF) -> None:
if self.resize_start is None:
self.begin_resize()
start = self.resize_start
edges = {
"left": start["x"],
"top": start["y"],
"right": start["x"] + start["width"],
"bottom": start["y"] + start["height"],
}
point = _snapped(scene_position)
minimum = _graph_snap_size()
if "w" in role:
edges["left"] = min(point.x(), edges["right"] - minimum)
if "e" in role:
edges["right"] = max(point.x(), edges["left"] + minimum)
if "n" in role:
edges["top"] = min(point.y(), edges["bottom"] - minimum)
if "s" in role:
edges["bottom"] = max(point.y(), edges["top"] + minimum)
self.prepareGeometryChange()
self.annotation.x, self.annotation.y = edges["left"], edges["top"]
self.annotation.width = edges["right"] - edges["left"]
self.annotation.height = edges["bottom"] - edges["top"]
self.setPos(self.annotation.x, self.annotation.y)
self._position_resize_handles()
self.update()
def finish_resize(self) -> None:
if self.resize_start is None:
return
old = self.resize_start
new = {
"x": self.annotation.x,
"y": self.annotation.y,
"width": self.annotation.width,
"height": self.annotation.height,
}
self.resize_start = None
self.controller.set_annotation_geometry(self.annotation_id, old, new)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
scene = self.scene() scene = self.scene()
if not isinstance(scene, GraphScene): if not isinstance(scene, GraphScene):
@@ -655,6 +780,7 @@ class GraphScene(QGraphicsScene):
elif isinstance(graphics, AnnotationGraphicsItem): elif isinstance(graphics, AnnotationGraphicsItem):
graphics.prepareGeometryChange() graphics.prepareGeometryChange()
graphics.setPos(annotation.x, annotation.y) graphics.setPos(annotation.x, annotation.y)
graphics._position_resize_handles()
graphics.update() graphics.update()
def edit_annotation_options(self, annotation_id: str) -> None: def edit_annotation_options(self, annotation_id: str) -> None:
@@ -951,6 +1077,13 @@ class GraphScene(QGraphicsScene):
] ]
return item, points return item, points
def route_graphics_item(self, item_kind: str, item_id: str) -> QGraphicsItem | None:
return (
self.connection_items.get(item_id)
if item_kind == "connection"
else self.annotation_items.get(item_id)
)
def add_route_node(self, item_kind: str, item_id: str, position: QPointF) -> None: def add_route_node(self, item_kind: str, item_id: str, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id) item, points = self._route_values(item_kind, item_id)
if item is None: if item is None:
@@ -986,6 +1119,8 @@ class GraphScene(QGraphicsScene):
) )
points.insert(insertion, snapped) points.insert(insertion, snapped)
routing = "angled" if item.properties.get("routing") == "direct" else None routing = "angled" if item.properties.get("routing") == "direct" else None
if routing == "angled" or item.properties.get("routing") == "angled":
points = self._orthogonal_points(start, end, points)[1:-1]
self.controller.set_route_waypoints(item_kind, item_id, points, routing) 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: def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
@@ -1012,6 +1147,14 @@ class GraphScene(QGraphicsScene):
return return
if index < len(points): if index < len(points):
points[index] = position points[index] = position
if item.properties.get("routing") == "angled":
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
points = self._orthogonal_points(start, end, points)[1:-1]
self.controller.set_route_waypoints(item_kind, item_id, points) self.controller.set_route_waypoints(item_kind, item_id, points)
def preview_route_node( def preview_route_node(

View File

@@ -31,7 +31,7 @@
"type": "rectangle", "type": "rectangle",
"x": 32.0, "x": 32.0,
"y": 32.0, "y": 32.0,
"width": 64.0, "width": 88.0,
"height": 64.0, "height": 64.0,
"fill": "#f4f4f4", "fill": "#f4f4f4",
"stroke": "#303030", "stroke": "#303030",
@@ -43,8 +43,8 @@
"type": "text", "type": "text",
"x": 40.0, "x": 40.0,
"y": 40.0, "y": 40.0,
"width": 48.0, "width": 72.0,
"height": 48.0, "height": 40.0,
"text": "Graph", "text": "Graph",
"color": "#202020", "color": "#202020",
"fontSize": 12.0 "fontSize": 12.0
@@ -288,9 +288,17 @@
"y": 64.0 "y": 64.0
}, },
{ {
"x": 64.0, "x": 0.0,
"y": 64.0 "y": 64.0
}, },
{
"x": 0.0,
"y": 120.0
},
{
"x": 64.0,
"y": 120.0
},
{ {
"x": 64.0, "x": 64.0,
"y": -80.0 "y": -80.0
@@ -308,8 +316,8 @@
"y": -328.0 "y": -328.0
}, },
"size": { "size": {
"width": 160.0, "width": 304.0,
"height": 112.0 "height": 160.0
}, },
"text": "", "text": "",
"layer": -1, "layer": -1,