diff --git a/BEdit/README.md b/BEdit/README.md index 1f9ac7f..29fba35 100644 --- a/BEdit/README.md +++ b/BEdit/README.md @@ -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. Right-click cancels an unfinished connection. - 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 share the Direct, Angled, and Spline routing controls. Annotation context menus 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 that component's contained subtree from the Libraries tree. - 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 `implementation.source` JSON. - Right-click any graph component under Current Document to add nested graph or diff --git a/BEdit/src/bedit/gui/graphics/icon_editor.py b/BEdit/src/bedit/gui/graphics/icon_editor.py index 992e949..6d8e0d7 100644 --- a/BEdit/src/bedit/gui/graphics/icon_editor.py +++ b/BEdit/src/bedit/gui/graphics/icon_editor.py @@ -34,50 +34,37 @@ def _snap(value: float) -> float: 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) self.owner = owner + self.role = role self.setBrush(QColor("#ffffff")) self.setPen(QPen(QColor("#2563eb"), 1.5)) - self.setCursor(Qt.CursorShape.SizeFDiagCursor) - self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) - self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) + self.setCursor(self.CURSORS[role]) self.setZValue(20) - def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: - maximum = ( - self.owner.scene().sceneRect().bottomRight() - self.owner.pos() - 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()))), - ) - 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) + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + 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 ShapeOptionsDialog(QDialog): @@ -178,9 +165,16 @@ class ShapeItem(QGraphicsObject): | 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() + roles = ( + ("n", "ne", "e", "se", "s", "sw", "w", "nw") + 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 margin = max(3.0, float(self.element.get("lineWidth", 1.5))) @@ -233,7 +227,8 @@ class ShapeItem(QGraphicsObject): elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: self.element["x"], self.element["y"] = value.x(), value.y() 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) 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()))), ) + 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 menu = QMenu() options = menu.addAction("Shape Options…") @@ -270,10 +333,7 @@ class ShapeItem(QGraphicsObject): 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)), - ) + self._position_resize_handles() self.update() elif chosen is delete and self.scene() is not None: self.scene().removeItem(self) diff --git a/BEdit/src/bedit/gui/graphics/workspace.py b/BEdit/src/bedit/gui/graphics/workspace.py index 734b175..5fede0f 100644 --- a/BEdit/src/bedit/gui/graphics/workspace.py +++ b/BEdit/src/bedit/gui/graphics/workspace.py @@ -217,9 +217,6 @@ 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: @@ -362,6 +359,20 @@ class WaypointHandle(QGraphicsEllipseItem): | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges ) 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 if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: @@ -373,12 +384,15 @@ class WaypointHandle(QGraphicsEllipseItem): 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()) + event.accept() def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 + if self.index < 0: + event.accept() + return menu = QMenu() delete_action = menu.addAction("Delete Node") if menu.exec(event.screenPos()) is delete_action: @@ -388,6 +402,40 @@ class WaypointHandle(QGraphicsEllipseItem): 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): def __init__(self, annotation: Annotation, controller: DocumentController) -> None: super().__init__() @@ -395,6 +443,7 @@ class AnnotationGraphicsItem(QGraphicsObject): self.annotation_id = annotation.id self.controller = controller self.drag_start = QPointF(annotation.x, annotation.y) + self.resize_start: dict[str, float] | None = None self.setPos(annotation.x, annotation.y) self.setZValue(annotation.layer) self.setFlags( @@ -402,6 +451,15 @@ class AnnotationGraphicsItem(QGraphicsObject): | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | 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 width, height = self.annotation.width, self.annotation.height @@ -457,8 +515,75 @@ class AnnotationGraphicsItem(QGraphicsObject): def itemChange(self, change, value): # noqa: N802 if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: return _snapped(value) + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged: + for handle in self.resize_handles: + handle.setVisible(bool(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 scene = self.scene() if not isinstance(scene, GraphScene): @@ -655,6 +780,7 @@ class GraphScene(QGraphicsScene): elif isinstance(graphics, AnnotationGraphicsItem): graphics.prepareGeometryChange() graphics.setPos(annotation.x, annotation.y) + graphics._position_resize_handles() graphics.update() def edit_annotation_options(self, annotation_id: str) -> None: @@ -951,6 +1077,13 @@ class GraphScene(QGraphicsScene): ] 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: item, points = self._route_values(item_kind, item_id) if item is None: @@ -986,6 +1119,8 @@ class GraphScene(QGraphicsScene): ) points.insert(insertion, snapped) 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) def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None: @@ -1012,6 +1147,14 @@ class GraphScene(QGraphicsScene): return if index < len(points): 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) def preview_route_node( diff --git a/BEdit/untitled.bedit.json b/BEdit/untitled.bedit.json index 9b8454e..f133eb5 100644 --- a/BEdit/untitled.bedit.json +++ b/BEdit/untitled.bedit.json @@ -31,7 +31,7 @@ "type": "rectangle", "x": 32.0, "y": 32.0, - "width": 64.0, + "width": 88.0, "height": 64.0, "fill": "#f4f4f4", "stroke": "#303030", @@ -43,8 +43,8 @@ "type": "text", "x": 40.0, "y": 40.0, - "width": 48.0, - "height": 48.0, + "width": 72.0, + "height": 40.0, "text": "Graph", "color": "#202020", "fontSize": 12.0 @@ -288,9 +288,17 @@ "y": 64.0 }, { - "x": 64.0, + "x": 0.0, "y": 64.0 }, + { + "x": 0.0, + "y": 120.0 + }, + { + "x": 64.0, + "y": 120.0 + }, { "x": 64.0, "y": -80.0 @@ -308,8 +316,8 @@ "y": -328.0 }, "size": { - "width": 160.0, - "height": 112.0 + "width": 304.0, + "height": 160.0 }, "text": "", "layer": -1,