diff --git a/BEdit/README.md b/BEdit/README.md index 834a60a..1509bcb 100644 --- a/BEdit/README.md +++ b/BEdit/README.md @@ -134,18 +134,18 @@ Every component owns its ports, declarative icon, properties, and child graph: "name": "My Component", "position": {"x": 0, "y": 0}, "interface": { - "inputs": [{"id": "in", "name": "Input", "properties": { + "inputs": [{"id": "in", "name": "Input", "type": "signal", "properties": { "iconPosition": {"x": 0, "y": 40} }}], - "outputs": [{"id": "out", "name": "Output", "properties": { - "iconPosition": {"x": 120, "y": 40} + "outputs": [{"id": "out", "name": "Output", "type": "signal", "properties": { + "iconPosition": {"x": 128, "y": 64} }}] }, "icon": { - "size": {"width": 120, "height": 80}, + "size": {"width": 128, "height": 128}, "elements": [{ "type": "rectangle", "x": 1, "y": 1, - "width": 118, "height": 78, + "width": 126, "height": 126, "cornerRadius": 5, "fill": "#dbeafe", "stroke": "#245c9c", "lineWidth": 1.5, "lineStyle": "solid" @@ -168,6 +168,8 @@ text components use `"implementation": {"kind": "text", "source": ...}` and never own a graph. Vector icons can contain rectangles, circles, ellipses, lines, triangles, and text. Each element owns its geometry, fill, stroke, and line style; ports keep their icon anchor in `properties.iconPosition`. The +port type registry controls which types may connect (currently `signal` only). +Graph and icon grid sizes are configured independently in Settings. The recursive model is under `src/bedit/document/`, library loading and the live Current Document tree are under `src/bedit/library/`, and graphics are isolated under `src/bedit/workspace/`. diff --git a/BEdit/src/bedit/document/controller.py b/BEdit/src/bedit/document/controller.py index 0d2d855..dccc380 100644 --- a/BEdit/src/bedit/document/controller.py +++ b/BEdit/src/bedit/document/controller.py @@ -29,6 +29,7 @@ from bedit.document.model import ( Port, clone_component, ) +from bedit.document.port_types import PortTypeRegistry from bedit.document.serializer import JsonDocumentSerializer @@ -227,12 +228,33 @@ class DocumentController(QObject): def connect(self, source: Endpoint, target: Endpoint) -> str: if self.active_component_id is None: raise ValueError("There is no active graph") + source_port = self._port_for_endpoint(source, "source") + target_port = self._port_for_endpoint(target, "target") + if source_port is None or target_port is None: + raise ValueError("A connection endpoint no longer exists") + if not PortTypeRegistry.compatible(source_port.type, target_port.type): + raise ValueError( + f"Cannot connect {source_port.type!r} to {target_port.type!r}" + ) connection = Connection(str(uuid4()), source, target) self.undo_stack.push( AddConnectionCommand(self, self.active_component_id, connection) ) return connection.id + def _port_for_endpoint(self, endpoint: Endpoint, role: str) -> Port | None: + owner = self.active_component + if owner is None: + return None + if endpoint.interface is not None: + ports = owner.inputs if role == "source" else owner.outputs + else: + component = owner.graph.blocks.get(endpoint.block or "") + if component is None: + return None + ports = component.outputs if role == "source" else component.inputs + return next((port for port in ports if port.id == (endpoint.interface or endpoint.port)), None) + def add_interface_port(self, direction: str, position: QPointF) -> str: component = self.active_component if component is None or component.implementation_kind != "graph": @@ -354,6 +376,55 @@ class DocumentController(QObject): "outputs": [port.to_dict() for port in outputs], "show_subtree": show_subtree, } + if old != new: + candidate = deepcopy(self.document) + candidate_component = candidate.find_component(component_id) + candidate_component.name = name + candidate_component.icon = Icon.from_dict(icon.to_dict()) + candidate_component.inputs = deepcopy(inputs) + candidate_component.outputs = deepcopy(outputs) + candidate.validate() + self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) + + def edit_component_ports( + self, component_id: str, inputs: list[Port], outputs: list[Port] + ) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + input_ids = {port.id for port in inputs} + output_ids = {port.id for port in outputs} + parent = self.document.find_parent(component_id) + if parent is not None: + for connection in parent.graph.connections.values(): + if connection.target.block == component_id and connection.target.port not in input_ids: + raise ValueError("An input cannot be removed or reoriented while connected") + if connection.source.block == component_id and connection.source.port not in output_ids: + raise ValueError("An output cannot be removed or reoriented while connected") + for connection in component.graph.connections.values(): + if connection.source.interface and connection.source.interface not in input_ids: + raise ValueError("An interface input cannot be removed while connected") + if connection.target.interface and connection.target.interface not in output_ids: + raise ValueError("An interface output cannot be removed while connected") + candidate = deepcopy(self.document) + candidate_component = candidate.find_component(component_id) + candidate_component.inputs = deepcopy(inputs) + candidate_component.outputs = deepcopy(outputs) + candidate.validate() + old = { + "name": component.name, + "icon": component.icon.to_dict(), + "inputs": [port.to_dict() for port in component.inputs], + "outputs": [port.to_dict() for port in component.outputs], + "show_subtree": component.show_subtree_in_library, + } + new = { + **old, + "inputs": [port.to_dict() for port in inputs], + "outputs": [port.to_dict() for port in outputs], + } if old != new: self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) diff --git a/BEdit/src/bedit/document/model.py b/BEdit/src/bedit/document/model.py index bac3ec0..abba8eb 100644 --- a/BEdit/src/bedit/document/model.py +++ b/BEdit/src/bedit/document/model.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any from uuid import uuid4 +from bedit.document.port_types import PortTypeRegistry + @dataclass class Port: @@ -13,6 +15,7 @@ class Port: x: float = 0.0 y: float = 0.0 properties: dict[str, Any] = field(default_factory=dict) + type: str = "signal" def to_dict(self) -> dict[str, Any]: return { @@ -20,6 +23,7 @@ class Port: "name": self.name, "position": {"x": self.x, "y": self.y}, "properties": self.properties, + "type": self.type, } @classmethod @@ -31,6 +35,7 @@ class Port: x=float(position.get("x", 0.0)), y=float(position.get("y", 0.0)), properties=dict(data.get("properties", {})), + type=str(data.get("type", "signal")), ) @@ -40,8 +45,8 @@ class Icon: fill: str = "#f4f4f4" border: str = "#303030" text: str = "" - width: float = 120.0 - height: float = 80.0 + width: float = 128.0 + height: float = 128.0 elements: list[dict[str, Any]] = field(default_factory=list) def __post_init__(self) -> None: @@ -72,14 +77,13 @@ class Icon: @classmethod def from_dict(cls, data: dict[str, Any] | None) -> "Icon": data = data or {} - size = data.get("size", {}) icon = cls( shape=str(data.get("shape", "rectangle")), fill=str(data.get("fill", "#f4f4f4")), border=str(data.get("border", "#303030")), text=str(data.get("text", "")), - width=float(size.get("width", 120.0)), - height=float(size.get("height", 80.0)), + width=128.0, + height=128.0, elements=deepcopy(data.get("elements", [])), ) return icon @@ -293,21 +297,31 @@ class GraphDocument: def _validate_graph(owner: Component) -> None: input_ids = {port.id for port in owner.inputs} output_ids = {port.id for port in owner.outputs} + if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs): + raise ValueError(f"Component {owner.name} contains duplicate port IDs") + for port in (*owner.inputs, *owner.outputs): + PortTypeRegistry.get(port.type) for connection in owner.graph.connections.values(): if connection.source.interface is not None: if connection.source.interface not in input_ids: raise ValueError(f"Connection {connection.id} uses an unknown interface input") + source_port = next(p for p in owner.inputs if p.id == connection.source.interface) else: source = owner.graph.blocks.get(connection.source.block or "") if source is None or connection.source.port not in {p.id for p in source.outputs}: raise ValueError(f"Connection {connection.id} uses an unknown block output") + source_port = next(p for p in source.outputs if p.id == connection.source.port) if connection.target.interface is not None: if connection.target.interface not in output_ids: raise ValueError(f"Connection {connection.id} uses an unknown interface output") + target_port = next(p for p in owner.outputs if p.id == connection.target.interface) else: target = owner.graph.blocks.get(connection.target.block or "") if target is None or connection.target.port not in {p.id for p in target.inputs}: raise ValueError(f"Connection {connection.id} uses an unknown block input") + target_port = next(p for p in target.inputs if p.id == connection.target.port) + if not PortTypeRegistry.compatible(source_port.type, target_port.type): + raise ValueError(f"Connection {connection.id} joins incompatible port types") def clone_component(source: Component) -> Component: @@ -341,8 +355,8 @@ def clone_component(source: Component) -> Component: name=current.name, x=current.x, y=current.y, - inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs], - outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs], + inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.inputs], + outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.outputs], icon=Icon.from_dict(current.icon.to_dict()), properties=deepcopy(current.properties), implementation_kind=current.implementation_kind, diff --git a/BEdit/src/bedit/document/port_types.py b/BEdit/src/bedit/document/port_types.py new file mode 100644 index 0000000..0b28fd6 --- /dev/null +++ b/BEdit/src/bedit/document/port_types.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PortType: + id: str + display_name: str + description: str = "" + + def accepts(self, other: "PortType") -> bool: + return self.id == other.id + + +class PortTypeRegistry: + _types = { + "signal": PortType("signal", "Signal", "A scalar signal connection"), + } + + @classmethod + def all(cls) -> tuple[PortType, ...]: + return tuple(cls._types.values()) + + @classmethod + def get(cls, type_id: str) -> PortType: + try: + return cls._types[type_id] + except KeyError as error: + raise ValueError(f"Unknown port type: {type_id}") from error + + @classmethod + def compatible(cls, first: str, second: str) -> bool: + return cls.get(first).accepts(cls.get(second)) diff --git a/BEdit/src/bedit/icon_editor.py b/BEdit/src/bedit/icon_editor.py index 8c9f63f..6dbf2df 100644 --- a/BEdit/src/bedit/icon_editor.py +++ b/BEdit/src/bedit/icon_editor.py @@ -1,6 +1,6 @@ from copy import deepcopy -from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtCore import QPointF, QRectF, QSettings, Qt from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF from PySide6.QtWidgets import ( QColorDialog, @@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( QGraphicsObject, QGraphicsScene, QGraphicsSceneContextMenuEvent, + QGraphicsSceneMouseEvent, QGraphicsView, QHBoxLayout, QInputDialog, @@ -30,6 +31,60 @@ from bedit.document.model import Component, Icon, Port from bedit.icon_renderer import _pen +def _icon_grid_size() -> int: + return QSettings().value("grid/iconSize", 8, type=int) + + +def _snap(value: float) -> float: + grid = _icon_grid_size() + return round(value / grid) * grid + + +class IconEditorView(QGraphicsView): + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802 + painter.fillRect(rect, QColor("#ffffff")) + grid = _icon_grid_size() + painter.setPen(QPen(QColor("#dbeafe"), 0)) + left = int(rect.left()) - int(rect.left()) % grid + top = int(rect.top()) - int(rect.top()) % grid + for x in range(left, int(rect.right()) + grid, grid): + painter.drawLine(x, rect.top(), x, rect.bottom()) + for y in range(top, int(rect.bottom()) + grid, grid): + painter.drawLine(rect.left(), y, rect.right(), y) + + +class ResizeHandle(QGraphicsEllipseItem): + def __init__(self, owner: "ShapeItem") -> None: + super().__init__(-4, -4, 8, 8, owner) + self.owner = owner + 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.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) + ) + 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 ColorButton(QPushButton): def __init__(self, color: str, allow_none: bool = False, parent=None) -> None: super().__init__(parent) @@ -128,6 +183,9 @@ class ShapeItem(QGraphicsObject): 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.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))) @@ -161,10 +219,38 @@ class ShapeItem(QGraphicsObject): painter.drawRect(rect) def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + 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()), + ), + ), + ) + 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)) return super().itemChange(change, value) + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + super().mouseReleaseEvent(event) + 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()))), + ) + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 menu = QMenu() options = menu.addAction("Shape Options…") @@ -176,6 +262,10 @@ class ShapeItem(QGraphicsObject): self.prepareGeometryChange() self.element.clear() self.element.update(dialog.element) + self.resize_handle.setPos( + float(self.element.get("width", 20)), + float(self.element.get("height", 20)), + ) self.update() elif chosen is delete and self.scene() is not None: self.scene().removeItem(self) @@ -195,10 +285,24 @@ class PortHandle(QGraphicsEllipseItem): self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: + bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128) + value = QPointF( + min(bounds.right(), max(bounds.left(), _snap(value.x()))), + min(bounds.bottom(), max(bounds.top(), _snap(value.y()))), + ) + elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()} return super().itemChange(change, value) + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + super().mouseReleaseEvent(event) + bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128) + self.setPos( + min(bounds.right(), max(bounds.left(), _snap(self.pos().x()))), + min(bounds.bottom(), max(bounds.top(), _snap(self.pos().y()))), + ) + class IconEditorDialog(QDialog): def __init__(self, component: Component, parent=None) -> None: @@ -222,11 +326,10 @@ class IconEditorDialog(QDialog): toolbar.addWidget(delete) layout.addLayout(toolbar) self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self) - self.view = QGraphicsView(self.scene) + self.view = IconEditorView(self.scene) self.view.setRenderHint(QPainter.RenderHint.Antialiasing) - self.view.setBackgroundBrush(QColor("#f8fafc")) self.view.setDragMode(QGraphicsView.DragMode.RubberBandDrag) - self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#94a3b8"), 0), QColor("#ffffff")) + self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100) layout.addWidget(self.view, 1) layout.addWidget(QLabel("Green points are inputs; red points are outputs. Drag them to place connection anchors.")) for element in self.icon.elements: diff --git a/BEdit/src/bedit/library/tree_model.py b/BEdit/src/bedit/library/tree_model.py index 0f116e5..b85e80a 100644 --- a/BEdit/src/bedit/library/tree_model.py +++ b/BEdit/src/bedit/library/tree_model.py @@ -12,6 +12,7 @@ from bedit.library.repository import LibraryRepository COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1 COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2 ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3 +COMPONENT_INSTANCE_ROLE = Qt.ItemDataRole.UserRole + 4 COMPONENT_MIME_TYPE = "application/x-bedit-component" @@ -48,6 +49,7 @@ class LibraryTreeModel(QStandardItemModel): item.setData(component.to_dict(), COMPONENT_ROLE) item.setData(component.id, COMPONENT_ID_ROLE) item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE) + item.setData(component, COMPONENT_INSTANCE_ROLE) if component.show_subtree_in_library: for child in component.graph.blocks.values(): item.appendRow(self._component_item(child, current=current)) diff --git a/BEdit/src/bedit/main_window.py b/BEdit/src/bedit/main_window.py index dec0c08..7134c0c 100644 --- a/BEdit/src/bedit/main_window.py +++ b/BEdit/src/bedit/main_window.py @@ -1,4 +1,5 @@ import json +from copy import deepcopy from pathlib import Path from PySide6.QtCore import QSettings, QSize, Qt, Slot @@ -7,16 +8,19 @@ from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox, QToo from bedit.component_options_dialog import ComponentOptionsDialog from bedit.document.controller import DocumentController -from bedit.document.model import Port +from bedit.document.model import Component, Port +from bedit.document.serializer import JsonDocumentSerializer from bedit.item_options_dialog import ItemOptionsDialog from bedit.library.repository import LibraryRepository from bedit.library.tree_model import ( COMPONENT_ID_ROLE, + COMPONENT_INSTANCE_ROLE, ITEM_KIND_ROLE, DocumentTreeModel, LibraryTreeModel, ) from bedit.settings_dialog import SettingsDialog +from bedit.port_options_dialog import PortOptionsDialog from bedit.ui_main_window import Ui_MainWindow @@ -63,6 +67,10 @@ class MainWindow(QMainWindow): self.ui.treeView.setHeaderHidden(True) self.ui.treeView.setDragEnabled(True) self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly) + self.ui.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.ui.treeView.customContextMenuRequested.connect( + self.show_external_library_context_menu + ) self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll) self.ui.documentTreeView.setModel(self.document_tree_model) self.ui.documentTreeView.setIconSize(QSize(16, 16)) @@ -81,6 +89,7 @@ class MainWindow(QMainWindow): self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll) self.ui.graphView.set_model(self.document_controller) self.ui.graphView.componentOptionsRequested.connect(self.show_component_options) + self.ui.graphView.componentPortOptionsRequested.connect(self.show_component_port_options) self.ui.graphView.portOptionsRequested.connect(self.show_port_options) self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options) self.ui.graphView.selectionAvailabilityChanged.connect( @@ -88,9 +97,8 @@ class MainWindow(QMainWindow): ) self.ui.navigateUpButton.clicked.connect(self.navigate_up) self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer")) - self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input")) - self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output")) - self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer")) + self.ui.inputToolButton.hide() + self.ui.outputToolButton.hide() self.ui.applyJsonButton.clicked.connect(self.apply_json) self.document_controller.activeGraphChanged.connect(self._active_graph_changed) @@ -199,12 +207,9 @@ class MainWindow(QMainWindow): self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text") self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage) self.ui.applyJsonButton.setVisible(not is_graph) - for button in ( - self.ui.pointerToolButton, - self.ui.inputToolButton, - self.ui.outputToolButton, - ): - button.setVisible(is_graph) + self.ui.pointerToolButton.setVisible(is_graph) + self.ui.inputToolButton.hide() + self.ui.outputToolButton.hide() if is_graph: self.set_graph_tool("pointer") else: @@ -229,13 +234,8 @@ class MainWindow(QMainWindow): self.document_controller.navigate_up() def set_graph_tool(self, mode: str) -> None: - self.ui.graphView.set_tool_mode(mode) - buttons = { - "pointer": self.ui.pointerToolButton, - "input": self.ui.inputToolButton, - "output": self.ui.outputToolButton, - } - buttons[mode].setChecked(True) + self.ui.graphView.set_tool_mode("pointer") + self.ui.pointerToolButton.setChecked(True) def _load_source_json(self) -> None: component = self.document_controller.active_component @@ -372,8 +372,14 @@ class MainWindow(QMainWindow): def show_settings(self) -> None: dialog = SettingsDialog(self) dialog.settingsChanged.connect(self.reload_libraries) + dialog.settingsChanged.connect(self.refresh_editor_settings) dialog.exec() + def refresh_editor_settings(self) -> None: + scene = self.ui.graphView.scene() + if scene is not None: + scene.update() + def _document_opened_changed(self, opened: bool) -> None: for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs): action.setEnabled(opened) @@ -406,6 +412,7 @@ class MainWindow(QMainWindow): text_action = menu.addAction("New Text Block") menu.addSeparator() options_action = menu.addAction("Component Options…") + ports_action = menu.addAction("Port Options…") delete_action = menu.addAction("Delete") selected = menu.exec(tree_view.viewport().mapToGlobal(position)) if selected is graph_action: @@ -414,6 +421,8 @@ class MainWindow(QMainWindow): self.document_controller.add_child(component_id, "text") elif selected is options_action: self.show_component_options(component_id) + elif selected is ports_action: + self.show_component_port_options(component_id) elif selected is delete_action: answer = QMessageBox.question( self, @@ -435,6 +444,56 @@ class MainWindow(QMainWindow): elif selected is text_action: self.document_controller.add_root("text") + @Slot(object) + def show_external_library_context_menu(self, position) -> None: + tree = self.ui.treeView + index = tree.indexAt(position) + component = index.data(COMPONENT_INSTANCE_ROLE) + if not isinstance(component, Component): + return + menu = QMenu(self) + ports_action = menu.addAction("Port Options…") + if menu.exec(tree.viewport().mapToGlobal(position)) is ports_action: + dialog = PortOptionsDialog(component, self) + if dialog.exec() == dialog.DialogCode.Accepted: + old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs) + component.inputs = dialog.inputs + component.outputs = dialog.outputs + library = next( + ( + library + for library in self.libraries.libraries + if any(item is component for item in library.document.all_components()) + ), + None, + ) + try: + if library is not None: + library.document.validate() + JsonDocumentSerializer.save( + library.document, Path(library.source_path) + ) + except (OSError, ValueError) as error: + component.inputs, component.outputs = old_inputs, old_outputs + QMessageBox.warning(self, "Cannot change library ports", str(error)) + self.library_tree_model.rebuild() + + @Slot(str) + def show_component_port_options(self, component_id: str) -> None: + document = self.document_controller.document + component = document.find_component(component_id) if document else None + if component is None: + return + dialog = PortOptionsDialog(component, self) + if dialog.exec() != dialog.DialogCode.Accepted: + return + try: + self.document_controller.edit_component_ports( + component_id, dialog.inputs, dialog.outputs + ) + except ValueError as error: + QMessageBox.warning(self, "Cannot change ports", str(error)) + @Slot(str) def show_component_options(self, component_id: str) -> None: document = self.document_controller.document diff --git a/BEdit/src/bedit/port_options_dialog.py b/BEdit/src/bedit/port_options_dialog.py new file mode 100644 index 0000000..d5b3e33 --- /dev/null +++ b/BEdit/src/bedit/port_options_dialog.py @@ -0,0 +1,162 @@ +from copy import deepcopy +from uuid import uuid4 + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) + +from bedit.document.model import Component, Port +from bedit.document.port_types import PortTypeRegistry + + +PORT_ROLE = Qt.ItemDataRole.UserRole + + +class PortOptionsDialog(QDialog): + """Unified editor for a component's typed, oriented ports.""" + + def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None: + super().__init__(parent) + self.setWindowTitle(f"Port Options — {component.name}") + self.resize(620, 380) + self.ports: list[tuple[Port, str]] = [ + *((deepcopy(port), "input") for port in component.inputs), + *((deepcopy(port), "output") for port in component.outputs), + ] + self._loading = False + layout = QVBoxLayout(self) + splitter = QSplitter() + left = QWidget() + left_layout = QVBoxLayout(left) + self.list = QListWidget() + self.list.currentRowChanged.connect(self._load_current) + left_layout.addWidget(self.list) + port_buttons = QHBoxLayout() + self.add_button = QPushButton("Add Port") + self.remove_button = QPushButton("Remove Port") + self.add_button.clicked.connect(self.add_port) + self.remove_button.clicked.connect(self.remove_port) + port_buttons.addWidget(self.add_button) + port_buttons.addWidget(self.remove_button) + left_layout.addLayout(port_buttons) + right = QWidget() + form = QFormLayout(right) + self.name_edit = QLineEdit() + self.type_combo = QComboBox() + for port_type in PortTypeRegistry.all(): + self.type_combo.addItem(port_type.display_name, port_type.id) + self.orientation_combo = QComboBox() + self.orientation_combo.addItem("Input", "input") + self.orientation_combo.addItem("Output", "output") + form.addRow("Name:", self.name_edit) + form.addRow("Type:", self.type_combo) + form.addRow("Orientation:", self.orientation_combo) + form.addRow("", QLabel("New ports start at (0, 0) in the icon editor.")) + self.name_edit.textEdited.connect(self._store_current) + self.type_combo.currentIndexChanged.connect(self._store_current) + self.orientation_combo.currentIndexChanged.connect(self._store_current) + splitter.addWidget(left) + splitter.addWidget(right) + splitter.setSizes([250, 370]) + layout.addWidget(splitter) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + if read_only: + self.add_button.setEnabled(False) + self.remove_button.setEnabled(False) + self.name_edit.setReadOnly(True) + self.type_combo.setEnabled(False) + self.orientation_combo.setEnabled(False) + buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Close") + buttons.button(QDialogButtonBox.StandardButton.Cancel).hide() + self._rebuild_list(0 if self.ports else -1) + + @property + def inputs(self) -> list[Port]: + return [port for port, orientation in self.ports if orientation == "input"] + + @property + def outputs(self) -> list[Port]: + return [port for port, orientation in self.ports if orientation == "output"] + + def _rebuild_list(self, row: int = -1) -> None: + self.list.clear() + for port, orientation in self.ports: + item = QListWidgetItem(f"{port.name} [{orientation}, {port.type}]") + item.setData(PORT_ROLE, port.id) + self.list.addItem(item) + self.list.setCurrentRow(min(row, len(self.ports) - 1)) + self._update_enabled() + + def _load_current(self, row: int) -> None: + self._loading = True + enabled = 0 <= row < len(self.ports) + if enabled: + port, orientation = self.ports[row] + self.name_edit.setText(port.name) + self.type_combo.setCurrentIndex(self.type_combo.findData(port.type)) + self.orientation_combo.setCurrentIndex(self.orientation_combo.findData(orientation)) + else: + self.name_edit.clear() + self._loading = False + self._update_enabled() + + def _update_enabled(self) -> None: + enabled = self.list.currentRow() >= 0 + self.remove_button.setEnabled(enabled) + self.name_edit.setEnabled(enabled) + self.type_combo.setEnabled(enabled) + self.orientation_combo.setEnabled(enabled) + + def _store_current(self) -> None: + row = self.list.currentRow() + if self._loading or not (0 <= row < len(self.ports)): + return + port, _orientation = self.ports[row] + port.name = self.name_edit.text() + port.type = self.type_combo.currentData() + self.ports[row] = (port, self.orientation_combo.currentData()) + self.list.item(row).setText( + f"{port.name} [{self.ports[row][1]}, {port.type}]" + ) + + def add_port(self) -> None: + port = Port( + id=f"port-{uuid4().hex[:8]}", + name=f"Port {len(self.ports) + 1}", + properties={"iconPosition": {"x": 0.0, "y": 0.0}}, + type="signal", + ) + self.ports.append((port, "input")) + self._rebuild_list(len(self.ports) - 1) + self.name_edit.selectAll() + self.name_edit.setFocus() + + def remove_port(self) -> None: + row = self.list.currentRow() + if row >= 0: + self.ports.pop(row) + self._rebuild_list(min(row, len(self.ports) - 1)) + + def accept(self) -> None: + self._store_current() + if any(not port.name.strip() for port, _orientation in self.ports): + QMessageBox.warning(self, "Invalid port", "Every port must have a name.") + return + super().accept() diff --git a/BEdit/src/bedit/settings_dialog.py b/BEdit/src/bedit/settings_dialog.py index 82f5c3f..77c0503 100644 --- a/BEdit/src/bedit/settings_dialog.py +++ b/BEdit/src/bedit/settings_dialog.py @@ -1,7 +1,7 @@ from pathlib import Path from PySide6.QtCore import QSettings, Signal -from PySide6.QtWidgets import QDialog, QFileDialog +from PySide6.QtWidgets import QDialog, QFileDialog, QFormLayout, QGroupBox, QSpinBox from bedit.library.repository import default_library_paths from bedit.ui_settings_dialog import Ui_SettingsDialog @@ -16,6 +16,17 @@ class SettingsDialog(QDialog): super().__init__(parent) self.ui = Ui_SettingsDialog() self.ui.setupUi(self) + self.grid_group = QGroupBox("Editor grids", self.ui.generalTab) + grid_form = QFormLayout(self.grid_group) + self.graph_grid_spin = QSpinBox() + self.graph_grid_spin.setRange(2, 256) + self.graph_grid_spin.setSuffix(" units") + self.icon_grid_spin = QSpinBox() + self.icon_grid_spin.setRange(1, 64) + self.icon_grid_spin.setSuffix(" units") + grid_form.addRow("Graph grid size:", self.graph_grid_spin) + grid_form.addRow("Icon grid size:", self.icon_grid_spin) + self.ui.generalLayout.insertWidget(1, self.grid_group) self.settings = QSettings() self.ui.addLibraryFileButton.clicked.connect(self._add_library_file) self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder) @@ -32,6 +43,8 @@ class SettingsDialog(QDialog): ) self.ui.libraryPathsList.clear() self.ui.libraryPathsList.addItems(self.library_paths(self.settings)) + self.graph_grid_spin.setValue(self.graph_grid_size(self.settings)) + self.icon_grid_spin.setValue(self.icon_grid_size(self.settings)) self._update_remove_button() @staticmethod @@ -42,6 +55,14 @@ class SettingsDialog(QDialog): return [value] return [str(path) for path in value] + @staticmethod + def graph_grid_size(settings: QSettings | None = None) -> int: + return (settings or QSettings()).value("grid/graphSize", 32, type=int) + + @staticmethod + def icon_grid_size(settings: QSettings | None = None) -> int: + return (settings or QSettings()).value("grid/iconSize", 8, type=int) + def _add_library_file(self) -> None: path, _ = QFileDialog.getOpenFileName( self, @@ -83,6 +104,8 @@ class SettingsDialog(QDialog): for row in range(self.ui.libraryPathsList.count()) ] self.settings.setValue("libraries/paths", paths) + self.settings.setValue("grid/graphSize", self.graph_grid_spin.value()) + self.settings.setValue("grid/iconSize", self.icon_grid_spin.value()) self.settings.sync() self.settingsChanged.emit() super().accept() diff --git a/BEdit/src/bedit/workspace/view.py b/BEdit/src/bedit/workspace/view.py index 6eab230..175086b 100644 --- a/BEdit/src/bedit/workspace/view.py +++ b/BEdit/src/bedit/workspace/view.py @@ -1,6 +1,6 @@ import json -from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal +from PySide6.QtCore import QMimeData, QPointF, QRectF, QSettings, Qt, Signal from PySide6.QtGui import ( QColor, QDragEnterEvent, @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( QApplication, QMenu, QStyleOptionGraphicsItem, + QToolTip, QWidget, ) @@ -36,6 +37,15 @@ from bedit.icon_renderer import paint_icon SELECTION_MIME_TYPE = "application/x-bedit-selection" +def _graph_grid_size() -> int: + return QSettings().value("grid/graphSize", 32, type=int) + + +def _snapped(position: QPointF) -> QPointF: + grid = _graph_grid_size() + return QPointF(round(position.x() / grid) * grid, round(position.y() / grid) * grid) + + class ConnectionPortItem(QGraphicsEllipseItem): def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None: super().__init__(-6, -6, 12, 12, parent) @@ -48,8 +58,8 @@ class ConnectionPortItem(QGraphicsEllipseItem): class ComponentGraphicsItem(QGraphicsObject): - WIDTH = 120.0 - HEIGHT = 72.0 + WIDTH = 128.0 + HEIGHT = 128.0 def __init__(self, component: Component, controller: DocumentController) -> None: super().__init__() @@ -109,10 +119,16 @@ class ComponentGraphicsItem(QGraphicsObject): self.setSelected(True) menu = QMenu() options_action = menu.addAction("Component Options…") - if menu.exec(event.screenPos()) is options_action: + ports_action = menu.addAction("Port Options…") + selected = menu.exec(event.screenPos()) + if selected is options_action: scene = self.scene() if isinstance(scene, GraphScene): scene.componentOptionsRequested.emit(self.component_id) + elif selected is ports_action: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.componentPortOptionsRequested.emit(self.component_id) event.accept() def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 @@ -121,10 +137,14 @@ class ComponentGraphicsItem(QGraphicsObject): def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 super().mouseReleaseEvent(event) - self.controller.move_component(self.component_id, self.drag_start, self.pos()) + snapped = _snapped(self.pos()) + self.setPos(snapped) + self.controller.move_component(self.component_id, self.drag_start, snapped) def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: + value = _snapped(value) + elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: scene = self.scene() if isinstance(scene, GraphScene): scene.update_connections_for_block(self.component_id) @@ -190,10 +210,14 @@ class InterfaceTerminalItem(QGraphicsObject): def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 super().mouseReleaseEvent(event) - self.controller.move_interface_port(self.port.id, self.drag_start, self.pos()) + snapped = _snapped(self.pos()) + self.setPos(snapped) + self.controller.move_interface_port(self.port.id, self.drag_start, snapped) def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: + value = _snapped(value) + elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: scene = self.scene() if isinstance(scene, GraphScene): scene.update_connections_for_interface(self.port.id) @@ -236,6 +260,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem): class GraphScene(QGraphicsScene): componentOptionsRequested = Signal(str) + componentPortOptionsRequested = Signal(str) portOptionsRequested = Signal(str, str) connectionOptionsRequested = Signal(str) @@ -311,13 +336,14 @@ class GraphScene(QGraphicsScene): painter.fillRect(rect, QColor("#e4e4e4")) if self.controller.document is None or self.controller.active_component is None: return - spacing = 32 + spacing = _graph_grid_size() left = int(rect.left()) - (int(rect.left()) % spacing) top = int(rect.top()) - (int(rect.top()) % spacing) - painter.setPen(QPen(QColor("#b8b8b8"), 1)) + painter.setPen(QPen(QColor("#b9c0c7"), 0)) for x in range(left, int(rect.right()) + spacing, spacing): - for y in range(top, int(rect.bottom()) + spacing, spacing): - painter.drawPoint(x, y) + painter.drawLine(x, rect.top(), x, rect.bottom()) + for y in range(top, int(rect.bottom()) + spacing, spacing): + painter.drawLine(rect.left(), y, rect.right(), y) def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None: if endpoint.interface is not None: @@ -354,7 +380,10 @@ class GraphScene(QGraphicsScene): item.setBrush(QColor("#f5b642")) elif self.pending_source is not None: if self.pending_source.endpoint != item.endpoint: - self.controller.connect(self.pending_source.endpoint, item.endpoint) + try: + self.controller.connect(self.pending_source.endpoint, item.endpoint) + except ValueError as error: + QToolTip.showText(event.screenPos(), str(error)) self._clear_pending_source() event.accept() return @@ -370,6 +399,7 @@ class GraphScene(QGraphicsScene): class GraphWorkspaceView(QGraphicsView): toolUsed = Signal() componentOptionsRequested = Signal(str) + componentPortOptionsRequested = Signal(str) portOptionsRequested = Signal(str, str) connectionOptionsRequested = Signal(str) selectionAvailabilityChanged = Signal(bool) @@ -417,6 +447,7 @@ class GraphWorkspaceView(QGraphicsView): self.controller = controller scene = GraphScene(controller, self) scene.componentOptionsRequested.connect(self.componentOptionsRequested) + scene.componentPortOptionsRequested.connect(self.componentPortOptionsRequested) scene.portOptionsRequested.connect(self.portOptionsRequested) scene.connectionOptionsRequested.connect(self.connectionOptionsRequested) scene.selectionChanged.connect( @@ -530,15 +561,6 @@ class GraphWorkspaceView(QGraphicsView): ) def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - if ( - self.controller is not None - and self.tool_mode in {"input", "output"} - and event.button() == Qt.MouseButton.LeftButton - ): - self.controller.add_interface_port(self.tool_mode, self.mapToScene(event.position().toPoint())) - self.toolUsed.emit() - event.accept() - return super().mousePressEvent(event) def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 @@ -569,5 +591,7 @@ class GraphWorkspaceView(QGraphicsView): return data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8")) source = Component.from_dict(data) - self.controller.add_component_copy(source, self.mapToScene(event.position().toPoint())) + self.controller.add_component_copy( + source, _snapped(self.mapToScene(event.position().toPoint())) + ) event.acceptProposedAction()