diff --git a/BEdit/src/bedit/gui/controllers/commands.py b/BEdit/src/bedit/gui/controllers/commands.py index bbae113..82b4a76 100644 --- a/BEdit/src/bedit/gui/controllers/commands.py +++ b/BEdit/src/bedit/gui/controllers/commands.py @@ -200,6 +200,19 @@ class EditComponentAppearanceCommand(QUndoCommand): self.controller._set_component_appearance(self.component_id, self.old) +class EditComponentPropertiesCommand(QUndoCommand): + def __init__(self, controller, component_id: str, old: dict, new: dict, text: str) -> None: + super().__init__(text) + self.controller, self.component_id = controller, component_id + self.old, self.new = old, new + + def redo(self) -> None: + self.controller._set_component_properties(self.component_id, self.new) + + def undo(self) -> None: + self.controller._set_component_properties(self.component_id, self.old) + + class RenameInterfacePortCommand(QUndoCommand): def __init__(self, controller, owner_id: str, port_id: str, old: str, new: str) -> None: super().__init__("Rename interface port") diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index 5fc1a7f..777149b 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -15,6 +15,7 @@ from bedit.gui.controllers.commands import ( EditGraphItemCommand, EditTextDefinitionCommand, EditComponentAppearanceCommand, + EditComponentPropertiesCommand, MoveComponentCommand, MoveInterfacePortCommand, PasteSelectionCommand, @@ -45,6 +46,7 @@ class DocumentController(QObject): componentRemoved = Signal(str) componentMoved = Signal(str, QPointF) componentRotated = Signal(str, float) + componentPropertiesChanged = Signal(str) connectionAdded = Signal(str) connectionRemoved = Signal(str) graphItemChanged = Signal(str, str) @@ -516,6 +518,7 @@ class DocumentController(QObject): inputs: list[Port], outputs: list[Port], show_subtree: bool, + show_name: bool, ) -> None: if self.document is None: return @@ -528,13 +531,20 @@ class DocumentController(QObject): "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, + "properties": deepcopy(component.properties), } + properties = deepcopy(component.properties) + was_visible = bool(properties.get("showName", False)) + properties["showName"] = show_name + if show_name and not was_visible: + properties.pop("nameLabelPosition", None) new = { "name": name, "icon": icon.to_dict(), "inputs": [port.to_dict() for port in inputs], "outputs": [port.to_dict() for port in outputs], "show_subtree": show_subtree, + "properties": properties, } if old != new: candidate = deepcopy(self.document) @@ -543,9 +553,75 @@ class DocumentController(QObject): candidate_component.icon = Icon.from_dict(icon.to_dict()) candidate_component.inputs = deepcopy(inputs) candidate_component.outputs = deepcopy(outputs) + candidate_component.properties = deepcopy(properties) candidate.validate() self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) + def move_component_name_label(self, component_id: str, position: QPointF) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + old = deepcopy(component.properties) + new = deepcopy(old) + new["nameLabelPosition"] = {"x": position.x(), "y": position.y()} + if old != new: + self.undo_stack.push( + EditComponentPropertiesCommand( + self, component_id, old, new, "Move component name" + ) + ) + + def edit_connection_options( + self, connection_id: str, name: str, show_name: bool + ) -> None: + owner = self.active_component + if owner is None or self.active_component_id is None: + return + connection = owner.graph.connections.get(connection_id) + if connection is None: + return + old = {"name": connection.name, "properties": deepcopy(connection.properties)} + properties = deepcopy(connection.properties) + was_visible = bool(properties.get("showName", False)) + properties["showName"] = show_name + if show_name and not was_visible: + properties.pop("nameLabelPosition", None) + new = {"name": name, "properties": properties} + if old != new: + self.undo_stack.push( + EditGraphItemCommand( + self, + self.active_component_id, + "connection_data", + connection_id, + old, + new, + "Edit connection options", + ) + ) + + def move_connection_name_label(self, connection_id: str, position: QPointF) -> None: + connection = self.active_graph.connections.get(connection_id) + if connection is None or self.active_component_id is None: + return + old = deepcopy(connection.properties) + new = deepcopy(old) + new["nameLabelPosition"] = {"x": position.x(), "y": position.y()} + if old != new: + self.undo_stack.push( + EditGraphItemCommand( + self, + self.active_component_id, + "connection", + connection_id, + old, + new, + "Move connection name", + ) + ) + def edit_component_ports( self, component_id: str, inputs: list[Port], outputs: list[Port] ) -> None: @@ -585,6 +661,7 @@ class DocumentController(QObject): "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, + "properties": deepcopy(component.properties), } new = { **old, @@ -750,7 +827,12 @@ class DocumentController(QObject): self, owner_id: str, item_kind: str, item_id: str, values: dict ) -> None: graph = self._graph_for(owner_id) - if item_kind == "connection": + if item_kind == "connection_data": + item = graph.connections.get(item_id) + if item is not None: + item.name = values["name"] + item.properties = deepcopy(values["properties"]) + elif item_kind == "connection": item = graph.connections.get(item_id) if item is not None: item.properties = deepcopy(values) @@ -867,10 +949,19 @@ class DocumentController(QObject): component.inputs = [Port.from_dict(port) for port in values["inputs"]] component.outputs = [Port.from_dict(port) for port in values["outputs"]] component.show_subtree_in_library = values["show_subtree"] + component.properties = deepcopy(values["properties"]) self.documentReset.emit() if component_id == self.active_component_id: self.activeGraphChanged.emit() + def _set_component_properties(self, component_id: str, properties: dict) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is not None: + component.properties = deepcopy(properties) + self.componentPropertiesChanged.emit(component_id) + def _delete_items( self, owner_id: str | None, diff --git a/BEdit/src/bedit/gui/dialogs/component_options.py b/BEdit/src/bedit/gui/dialogs/component_options.py index f1497c1..6862286 100644 --- a/BEdit/src/bedit/gui/dialogs/component_options.py +++ b/BEdit/src/bedit/gui/dialogs/component_options.py @@ -17,6 +17,7 @@ class ComponentOptionsDialog(QDialog): self.ui.nameEdit.setText(component.name) self.ui.editIconButton.clicked.connect(self.edit_icon) self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library) + self.ui.showNameCheckBox.setChecked(bool(component.properties.get("showName", False))) def edit_icon(self) -> None: working = Component.from_dict(self.component.to_dict()) diff --git a/BEdit/src/bedit/gui/dialogs/item_options.py b/BEdit/src/bedit/gui/dialogs/item_options.py index 5227c49..39a519d 100644 --- a/BEdit/src/bedit/gui/dialogs/item_options.py +++ b/BEdit/src/bedit/gui/dialogs/item_options.py @@ -1,6 +1,7 @@ from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, + QCheckBox, QFormLayout, QLineEdit, QMessageBox, @@ -11,7 +12,15 @@ from PySide6.QtWidgets import ( class ItemOptionsDialog(QDialog): """Small, extensible options dialog shared by ports and connections.""" - def __init__(self, title: str, name: str, parent=None, *, name_required: bool = True) -> None: + def __init__( + self, + title: str, + name: str, + parent=None, + *, + name_required: bool = True, + show_name: bool | None = None, + ) -> None: super().__init__(parent) self.name_required = name_required self.setWindowTitle(title) @@ -21,6 +30,11 @@ class ItemOptionsDialog(QDialog): self.form = QFormLayout() self.name_edit = QLineEdit(name, self) self.form.addRow("Name:", self.name_edit) + self.show_name_check = None + if show_name is not None: + self.show_name_check = QCheckBox("Show name below connection", self) + self.show_name_check.setChecked(show_name) + self.form.addRow("", self.show_name_check) layout.addLayout(self.form) buttons = QDialogButtonBox( @@ -35,6 +49,10 @@ class ItemOptionsDialog(QDialog): def name(self) -> str: return self.name_edit.text().strip() + @property + def show_name(self) -> bool: + return bool(self.show_name_check and self.show_name_check.isChecked()) + def accept(self) -> None: if self.name_required and not self.name: QMessageBox.warning(self, "Invalid name", "The name cannot be empty.") diff --git a/BEdit/src/bedit/gui/generated/ui_component_options_dialog.py b/BEdit/src/bedit/gui/generated/ui_component_options_dialog.py index 05402b9..d2d4b3f 100644 --- a/BEdit/src/bedit/gui/generated/ui_component_options_dialog.py +++ b/BEdit/src/bedit/gui/generated/ui_component_options_dialog.py @@ -55,6 +55,11 @@ class Ui_ComponentOptionsDialog(object): self.optionsForm.setWidget(2, QFormLayout.ItemRole.SpanningRole, self.showSubtreeCheckBox) + self.showNameCheckBox = QCheckBox(ComponentOptionsDialog) + self.showNameCheckBox.setObjectName(u"showNameCheckBox") + + self.optionsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.showNameCheckBox) + self.dialogLayout.addLayout(self.optionsForm) @@ -85,5 +90,6 @@ class Ui_ComponentOptionsDialog(object): self.editIconButton.setToolTip(QCoreApplication.translate("ComponentOptionsDialog", u"Open the vector icon and port-position editor", None)) #endif // QT_CONFIG(tooltip) self.showSubtreeCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show contained components in the Libraries tree", None)) + self.showNameCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show name below component", None)) # retranslateUi diff --git a/BEdit/src/bedit/gui/graphics/workspace.py b/BEdit/src/bedit/gui/graphics/workspace.py index a9e40bf..10b1697 100644 --- a/BEdit/src/bedit/gui/graphics/workspace.py +++ b/BEdit/src/bedit/gui/graphics/workspace.py @@ -23,6 +23,7 @@ from PySide6.QtWidgets import ( QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, + QGraphicsSimpleTextItem, QGraphicsView, QApplication, QMenu, @@ -78,6 +79,48 @@ class ConnectionPortItem(QGraphicsEllipseItem): self.setToolTip(label) +class NameLabelItem(QGraphicsSimpleTextItem): + """Movable italic name label whose position is stored by its owner.""" + + def __init__( + self, + text: str, + owner_kind: str, + owner_id: str, + controller: DocumentController, + parent: QGraphicsItem | None = None, + ) -> None: + super().__init__(text, parent) + self.owner_kind = owner_kind + self.owner_id = owner_id + self.controller = controller + font = self.font() + font.setItalic(True) + self.setFont(font) + self.setBrush(QColor("#303030")) + self.setCursor(Qt.CursorShape.SizeAllCursor) + self.drag_offset = QPointF() + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + parent = self.parentItem() + point = parent.mapFromScene(event.scenePos()) if parent else event.scenePos() + self.drag_offset = self.pos() - point + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + parent = self.parentItem() + point = parent.mapFromScene(event.scenePos()) if parent else event.scenePos() + self.setPos(_snapped(point + self.drag_offset)) + event.accept() + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + if self.owner_kind == "component": + self.controller.move_component_name_label(self.owner_id, self.pos()) + else: + self.controller.move_connection_name_label(self.owner_id, self.pos()) + event.accept() + + class ComponentGraphicsItem(QGraphicsObject): WIDTH = 128.0 HEIGHT = 128.0 @@ -98,6 +141,31 @@ class ComponentGraphicsItem(QGraphicsObject): self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH) self.setTransformOriginPoint(self.hitbox.center()) self.setRotation(component.rotation) + self.name_label: NameLabelItem | None = None + self.sync_name_label(component) + + def sync_name_label(self, component: Component) -> None: + if component.properties.get("showName", False): + if self.name_label is None: + self.name_label = NameLabelItem( + component.name, "component", component.id, self.controller, self + ) + self.name_label.setText(component.name) + position = component.properties.get("nameLabelPosition") + if isinstance(position, dict): + self.name_label.setPos(float(position["x"]), float(position["y"])) + else: + bounds = self.name_label.boundingRect() + self.name_label.setPos( + self.hitbox.center().x() - bounds.width() / 2, + self.hitbox.bottom() + 6, + ) + self.name_label.setRotation(-component.rotation) + elif self.name_label is not None: + if self.name_label.scene() is not None: + self.name_label.setParentItem(None) + self.name_label.scene().removeItem(self.name_label) + self.name_label = None def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]: result = {} @@ -255,13 +323,14 @@ class InterfaceTerminalItem(QGraphicsObject): class ConnectionGraphicsItem(QGraphicsPathItem): def __init__( self, - connection_id: str, - name: str = "", + connection: Connection, + controller: DocumentController, style: ConnectionStyle | None = None, ) -> None: super().__init__() - self.connection_id = connection_id - self.name = name + self.connection_id = connection.id + self.name = connection.name + self.controller = controller self.style = style or ConnectionStyle() self.start = QPointF() self.end = QPointF() @@ -270,8 +339,34 @@ class ConnectionGraphicsItem(QGraphicsPathItem): self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self._update_pen() self.setZValue(0) - self.setToolTip(name or "Connection") + self.setToolTip(connection.name or "Connection") self.handles: list[WaypointHandle] = [] + self.name_label: NameLabelItem | None = None + self.sync_name_label(connection) + + def sync_name_label(self, connection: Connection) -> None: + visible = bool(connection.properties.get("showName", False)) + if not visible: + if self.name_label is not None and self.name_label.scene() is not None: + self.name_label.setParentItem(None) + self.name_label.scene().removeItem(self.name_label) + self.name_label = None + return + if self.name_label is None: + self.name_label = NameLabelItem( + connection.name, "connection", connection.id, self.controller, self + ) + self.name_label.setText(connection.name) + position = connection.properties.get("nameLabelPosition") + if isinstance(position, dict): + self.name_label.setPos(float(position["x"]), float(position["y"])) + elif not self.path().isEmpty(): + bounds = self.path().boundingRect() + label_bounds = self.name_label.boundingRect() + self.name_label.setPos( + bounds.center().x() - label_bounds.width() / 2, + bounds.bottom() + 6, + ) def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 if not self.isSelected(): @@ -318,6 +413,10 @@ class ConnectionGraphicsItem(QGraphicsPathItem): if len(points) > 1: self.start_direction = points[1] - points[0] self.end_direction = points[-1] - points[-2] + if self.name_label is not None: + connection = self.controller.active_graph.connections.get(self.connection_id) + if connection is not None: + self.sync_name_label(connection) def set_waypoints(self, points: list[QPointF]) -> None: for handle in self.handles: @@ -713,6 +812,7 @@ class GraphScene(QGraphicsScene): controller.activeGraphChanged.connect(self.rebuild) controller.componentMoved.connect(self.set_component_position) controller.componentRotated.connect(self.set_component_rotation) + controller.componentPropertiesChanged.connect(self.refresh_component_properties) controller.graphItemChanged.connect(self.refresh_graph_item) self.rebuild() @@ -746,8 +846,8 @@ class GraphScene(QGraphicsScene): self.component_items[component.id] = item for connection in owner.graph.connections.values(): item = ConnectionGraphicsItem( - connection.id, - connection.name, + connection, + self.controller, connection_style(self.controller.connection_port_type(connection)), ) self.addItem(item) @@ -774,10 +874,22 @@ class GraphScene(QGraphicsScene): item = self.component_items.get(component_id) if item is not None: item.setRotation(rotation) + if item.name_label is not None: + item.name_label.setRotation(-rotation) self.update_connections_for_block(component_id) + def refresh_component_properties(self, component_id: str) -> None: + component = ( + self.controller.document.find_component(component_id) + if self.controller.document + else None + ) + item = self.component_items.get(component_id) + if component is not None and item is not None: + item.sync_name_label(component) + def refresh_graph_item(self, item_kind: str, item_id: str) -> None: - if item_kind == "connection": + if item_kind in {"connection", "connection_data"}: self.update_connection(item_id) return annotation = self.controller.active_graph.annotations.get(item_id) @@ -908,6 +1020,7 @@ class GraphScene(QGraphicsScene): start, end = geometry path, direction_points = self._route_path(start, end, waypoints) graphics.set_connection_path(path, direction_points) + graphics.sync_name_label(connection) graphics.set_waypoints(waypoints) def update_annotation(self, annotation_id: str) -> None: diff --git a/BEdit/src/bedit/gui/main_window.py b/BEdit/src/bedit/gui/main_window.py index 17295dd..c3dd287 100644 --- a/BEdit/src/bedit/gui/main_window.py +++ b/BEdit/src/bedit/gui/main_window.py @@ -131,6 +131,7 @@ class MainWindow(QMainWindow): self.ui.actionPaste.triggered.connect(self.ui.graphView.paste_selection) self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all) self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected) + self.addAction(self.ui.actionRotateClockwise) self.ui.actionZoomIn.triggered.connect(self.ui.graphView.zoom_in) self.ui.actionZoomOut.triggered.connect(self.ui.graphView.zoom_out) self.ui.actionCenterView.triggered.connect(self.ui.graphView.center_workspace) @@ -527,6 +528,7 @@ class MainWindow(QMainWindow): dialog.edited_inputs, dialog.edited_outputs, dialog.ui.showSubtreeCheckBox.isChecked(), + dialog.ui.showNameCheckBox.isChecked(), ) @Slot(str, str) @@ -550,9 +552,17 @@ class MainWindow(QMainWindow): connection = owner.graph.connections.get(connection_id) if connection is None: return - dialog = ItemOptionsDialog("Connection Options", connection.name, self, name_required=False) + dialog = ItemOptionsDialog( + "Connection Options", + connection.name, + self, + name_required=False, + show_name=bool(connection.properties.get("showName", False)), + ) if dialog.exec() == dialog.DialogCode.Accepted: - self.document_controller.rename_connection(connection_id, dialog.name) + self.document_controller.edit_connection_options( + connection_id, dialog.name, dialog.show_name + ) @Slot() def show_about(self) -> None: diff --git a/BEdit/ui/component_options_dialog.ui b/BEdit/ui/component_options_dialog.ui index ca077b0..7cca48f 100644 --- a/BEdit/ui/component_options_dialog.ui +++ b/BEdit/ui/component_options_dialog.ui @@ -12,6 +12,7 @@ Icon: Edit Icon…Open the vector icon and port-position editor Show contained components in the Libraries treetrue + Show name below component Qt::Orientation::Vertical2040 diff --git a/BEdit/untitled.bedit.json b/BEdit/untitled.bedit.json index 265e714..de3fae5 100644 --- a/BEdit/untitled.bedit.json +++ b/BEdit/untitled.bedit.json @@ -6,7 +6,7 @@ }, "roots": [ { - "id": "f4a9c769-d49a-46e1-90d8-a8175bf42e9c", + "id": "fd375563-d8cb-4c7b-b686-cd6f1edbb654", "name": "New Graph Block 1", "position": { "x": 0.0, @@ -31,7 +31,7 @@ "type": "rectangle", "x": 32.0, "y": 32.0, - "width": 88.0, + "width": 64.0, "height": 64.0, "fill": "#f4f4f4", "stroke": "#303030", @@ -43,8 +43,8 @@ "type": "text", "x": 40.0, "y": 40.0, - "width": 72.0, - "height": 40.0, + "width": 48.0, + "height": 48.0, "text": "Graph", "color": "#202020", "fontSize": 12.0 @@ -60,11 +60,11 @@ "graph": { "blocks": [ { - "id": "af73487a-4a2d-4908-b919-d0a2eaa75ce2", - "name": "A", + "id": "ffca7e20-ca0f-4872-985a-715dcd985052", + "name": "test A", "position": { "x": -224.0, - "y": -168.0 + "y": -96.0 }, "rotation": 0.0, "interface": { @@ -141,7 +141,13 @@ } ] }, - "properties": {}, + "properties": { + "showName": true, + "nameLabelPosition": { + "x": 48.0, + "y": 104.0 + } + }, "library": { "showSubtree": true }, @@ -154,11 +160,11 @@ } }, { - "id": "b89a77a1-d773-4ee3-8c3f-74377c29b4b2", - "name": "B", + "id": "571fd107-61ad-496a-8270-014e5697fcd1", + "name": "test B", "position": { "x": 96.0, - "y": -288.0 + "y": -160.0 }, "rotation": 0.0, "interface": { @@ -250,7 +256,9 @@ } ] }, - "properties": {}, + "properties": { + "showName": true + }, "library": { "showSubtree": true }, @@ -266,31 +274,23 @@ ], "connections": [ { - "id": "87490296-e353-43ea-8b59-55329950fdce", + "id": "df5ecb33-5451-4295-b68d-20886d86f0f4", "source": { - "block": "af73487a-4a2d-4908-b919-d0a2eaa75ce2", + "block": "ffca7e20-ca0f-4872-985a-715dcd985052", "port": "port-de109124" }, "target": { - "block": "b89a77a1-d773-4ee3-8c3f-74377c29b4b2", + "block": "571fd107-61ad-496a-8270-014e5697fcd1", "port": "port-4f732b3e" }, - "name": "", + "name": "conn", "properties": { - "waypoints": [ - { - "x": -64.0, - "y": -192.0 - }, - { - "x": -64.0, - "y": -256.0 - }, - { - "x": 64.0, - "y": -256.0 - } - ] + "waypoints": [], + "showName": true, + "nameLabelPosition": { + "x": -8.0, + "y": -56.0 + } } } ],