From a61c8e600338c683b5c8c64db0ebaf4b36ead4bd Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 17 Aug 2026 13:03:29 +0200 Subject: [PATCH] Labels --- src/bedit_gui/commands/graph_label_command.py | 24 +++++ .../controllers/document_tree_controller.py | 22 ++++- src/bedit_gui/documents/document.py | 34 ++++++- src/bedit_gui/models.py | 18 +++- src/bedit_gui/views/graph_editor_widget.py | 90 ++++++++++++++++++- untitled.bedit.json | 23 +++++ 6 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 src/bedit_gui/commands/graph_label_command.py diff --git a/src/bedit_gui/commands/graph_label_command.py b/src/bedit_gui/commands/graph_label_command.py new file mode 100644 index 0000000..838e6f3 --- /dev/null +++ b/src/bedit_gui/commands/graph_label_command.py @@ -0,0 +1,24 @@ +from copy import deepcopy + +from PySide6.QtGui import QUndoCommand + +from bedit_core.models import ComponentID +from bedit_gui.models import GraphComponentLabel + + +class ChangeGraphComponentLabelCommand(QUndoCommand): + def __init__(self, document: object, graph_id: ComponentID, component_id: ComponentID, label: GraphComponentLabel, text: str) -> None: + super().__init__(text) + self.document = document + self.graph_id = graph_id + self.component_id = component_id + database = document._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + self.old_label = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else None + self.new_label = deepcopy(label) + + def redo(self) -> None: + self.document._set_graph_component_label(self.graph_id, self.component_id, self.new_label) + + def undo(self) -> None: + self.document._set_graph_component_label(self.graph_id, self.component_id, self.old_label) diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index f90492f..75103bd 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -9,7 +9,7 @@ from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument from bedit_gui.documents import Document -from bedit_gui.models import Graph, Icon +from bedit_gui.models import Graph, GraphComponentLabel, Icon from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog from bedit_gui.views.icon_editor_window import IconEditorWindow @@ -63,11 +63,13 @@ class DocumentTreeController(QObject): document.model_changed.connect(self._on_document_changed) document.icon_changed.connect(self._on_icon_changed) document.graph_component_position_changed.connect(self._on_graph_component_position_changed) + document.graph_component_label_changed.connect(self._on_graph_component_label_changed) document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed) document.equation_text_changed.connect(self._on_equation_text_changed) self.model.rename_document_requested.connect(self.document.rename) self.model.rename_component_requested.connect(self.document.rename_component) window.graph_editor.component_move_requested.connect(self.document.move_graph_component) + window.graph_editor.component_label_move_requested.connect(self.document.move_graph_component_label) window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu) window.graph_editor.component_open_requested.connect(self._open_graph_component) window.graph_editor.connection_points_change_requested.connect(self.document.change_graph_connection_points) @@ -155,6 +157,11 @@ class DocumentTreeController(QObject): if graph_component is not None and self.document.component_id(graph_component) == graph_id: self.window.graph_editor.set_component_position(component_id, position) + def _on_graph_component_label_changed(self, graph_id: ComponentID, component_id: ComponentID, label: object) -> None: + graph_component = self.window.graph_editor.component() + if graph_component is not None and self.document.component_id(graph_component) == graph_id: + self.window.graph_editor.set_component_label(component_id, label if isinstance(label, GraphComponentLabel) else None) + def _on_graph_connection_points_changed(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None: graph_component = self.window.graph_editor.component() if graph_component is not None and self.document.component_id(graph_component) == graph_id: @@ -177,7 +184,7 @@ class DocumentTreeController(QObject): def _show_graph_component_context_menu(self, component_id: ComponentID, global_position: QPoint) -> None: component = self._components.get(component_id) if component is not None: - self._show_component_context_menu(component, global_position) + self._show_component_context_menu(component, global_position, component_id) def _open_graph_component(self, component_id: ComponentID) -> None: index = self.model.component_index(component_id) @@ -185,11 +192,16 @@ class DocumentTreeController(QObject): self.window.ui.documentTree.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows) self.window.ui.documentTree.scrollTo(index) - def _show_component_context_menu(self, component: Component, global_position: QPoint) -> None: + def _show_component_context_menu(self, component: Component, global_position: QPoint, graph_component_id: ComponentID | None = None) -> None: menu = QMenu(self.window.ui.documentTree) edit_interface = menu.addAction("Edit Interface") edit_params = menu.addAction("Edit Parameters") edit_icon = menu.addAction("Edit Icon") + show_label = None + if graph_component_id is not None: + show_label = menu.addAction("Show Label") + show_label.setCheckable(True) + show_label.setChecked(self.window.graph_editor.component_label_visible(graph_component_id)) menu.addSeparator() add_graph_component = None add_equation_component = None @@ -205,6 +217,10 @@ class DocumentTreeController(QObject): self._edit_params(component) elif selected is edit_icon: self._edit_icon(component) + elif show_label is not None and selected is show_label: + graph_component = self.window.graph_editor.component() + if graph_component is not None: + self.document.set_graph_component_label_visible(graph_component, graph_component_id, show_label.isChecked()) elif add_graph_component is not None and selected is add_graph_component: self._add_graph_component(component) elif add_equation_component is not None and selected is add_equation_component: diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index 6dc38d5..ac68569 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -15,13 +15,14 @@ from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnectionPointsCommand from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand +from bedit_gui.commands.graph_label_command import ChangeGraphComponentLabelCommand from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents -from bedit_gui.models import Graph, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase +from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase from bedit_gui.services import document_files @@ -35,6 +36,7 @@ class Document(QObject): equation_text_changed = Signal(object, str) simulation_database_changed = Signal(object) graph_component_position_changed = Signal(object, object, object) + graph_component_label_changed = Signal(object, object, object) graph_connection_points_changed = Signal(object, object, object) def __init__(self, parent: QObject | None = None) -> None: @@ -157,6 +159,36 @@ class Document(QObject): graph.component_positions[component_id] = position self.graph_component_position_changed.emit(graph_id, component_id, position) + def move_graph_component_label(self, graph_component: Component, component_id: ComponentID, relative_position: tuple[int, int]) -> None: + graph_id = self.component_id(graph_component) + database = self._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + current = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else GraphComponentLabel() + if current.relative_position != relative_position: + current.relative_position = relative_position + self.undo_stack.push(ChangeGraphComponentLabelCommand(self, graph_id, component_id, current, "Move component label")) + + def set_graph_component_label_visible(self, graph_component: Component, component_id: ComponentID, visible: bool) -> None: + graph_id = self.component_id(graph_component) + database = self._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + current = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else GraphComponentLabel() + if current.visible != visible: + current.visible = visible + self.undo_stack.push(ChangeGraphComponentLabelCommand(self, graph_id, component_id, current, "Show component label" if visible else "Hide component label")) + + def _set_graph_component_label(self, graph_id: ComponentID, component_id: ComponentID, label: GraphComponentLabel | None) -> None: + if label is None: + database = self._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + if graph is not None: + graph.component_labels.pop(component_id, None) + else: + database = self._graph_database(True) + graph = database.graphs.setdefault(graph_id, Graph()) + graph.component_labels[component_id] = deepcopy(label) + self.graph_component_label_changed.emit(graph_id, component_id, deepcopy(label)) + def _graph_database(self, create: bool) -> GraphDatabase | None: metadata = self.model.metadata value = metadata.get("graph_database") if metadata is not None else None diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py index 69ab87c..de86253 100644 --- a/src/bedit_gui/models.py +++ b/src/bedit_gui/models.py @@ -134,23 +134,39 @@ class GraphConnection: def to_data(self) -> dict[str, Any]: return {"points": [list(point) for point in self.points]} +@dataclass +class GraphComponentLabel: + relative_position: tuple[int, int] = (0, 8) + visible: bool = True + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> GraphComponentLabel: + position = data.get("relative_position", [0, 8]) + return cls(relative_position=(int(position[0]), int(position[1])), visible=bool(data.get("visible", True))) + + def to_data(self) -> dict[str, Any]: + return {"relative_position": list(self.relative_position), "visible": self.visible} + @dataclass class Graph: shapes: dict[ShapeID, Shape] = field(default_factory=dict) component_positions: dict[ComponentID, tuple[int, int]] = field(default_factory=dict) connections: dict[ConnectionID, GraphConnection] = field(default_factory=dict) + component_labels: dict[ComponentID, GraphComponentLabel] = field(default_factory=dict) @classmethod def from_data(cls, data: Mapping[str, Any]) -> Graph: shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()} component_positions = {ComponentID(key): (int(value[0]), int(value[1])) for key, value in data.get("component_positions", {}).items()} + component_labels = {ComponentID(key): GraphComponentLabel.from_data(value) for key, value in data.get("component_labels", {}).items()} connections = {ConnectionID(key): GraphConnection.from_data(value) for key, value in data.get("connections", {}).items()} - return cls(shapes=shapes, component_positions=component_positions, connections=connections) + return cls(shapes=shapes, component_positions=component_positions, component_labels=component_labels, connections=connections) def to_data(self) -> dict[str, Any]: return { "shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}, "component_positions": {str(key): list(position) for key, position in self.component_positions.items()}, + "component_labels": {str(key): label.to_data() for key, label in self.component_labels.items()}, "connections": {str(key): connection.to_data() for key, connection in self.connections.items()}, } diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py index 570e467..e2c37e0 100644 --- a/src/bedit_gui/views/graph_editor_widget.py +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -6,10 +6,10 @@ from math import hypot from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent -from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QMenu, QWidget +from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsTextItem, QGraphicsView, QMenu, QWidget from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort -from bedit_gui.models import Graph, GraphConnection, Icon +from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon @@ -20,6 +20,7 @@ MAX_ZOOM = 4.0 ZOOM_STEP = 1.15 ZOOM_TO_FIT_PADDING = 32.0 COMPONENT_ICON_SIZE = QSize(128, 128) +COMPONENT_LABEL_FONT_SIZE = 24.0 FALLBACK_COMPONENT_SPACING = 128 CONNECTION_WIDTH = 4.0 CONNECTION_BOUNDING_BOX_SPACING = 16.0 @@ -184,6 +185,58 @@ class GraphComponentItem(QGraphicsPixmapItem): event.accept() +class GraphComponentLabelItem(QGraphicsTextItem): + def __init__(self, component_id: ComponentID, text: str, label: GraphComponentLabel, component_item: GraphComponentItem, editor: GraphEditorWidget) -> None: + super().__init__(text, component_item) + self.component_id = component_id + self.editor = editor + self._dragging = False + self._drag_start = label.relative_position + font = self.font() + font.setItalic(True) + font.setPointSizeF(COMPONENT_LABEL_FONT_SIZE) + self.setFont(font) + self.setDefaultTextColor(QColor("#202020")) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) + self.set_relative_position(label.relative_position) + + def relative_position(self) -> tuple[int, int]: + component_bounds = self.editor._component_bounds[self.component_id] + return round(self.pos().x() + self.boundingRect().width() / 2), round(self.pos().y() - component_bounds.bottom()) + + def set_relative_position(self, position: tuple[int, int]) -> None: + component_bounds = self.editor._component_bounds[self.component_id] + self.setPos(position[0] - self.boundingRect().width() / 2, component_bounds.bottom() + position[1]) + + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF): + component_bounds = self.editor._component_bounds[self.component_id] + size = self.editor.snap_to_grid_size + relative_x = value.x() + self.boundingRect().width() / 2 + relative_y = value.y() - component_bounds.bottom() + value = QPointF(round(relative_x / size) * size - self.boundingRect().width() / 2, component_bounds.bottom() + round(relative_y / size) * size) + return super().itemChange(change, value) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self._drag_start = self.relative_position() + self._dragging = True + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + super().mouseReleaseEvent(event) + self._dragging = False + position = self.relative_position() + self.set_relative_position(position) + if position != self._drag_start: + self.editor.finish_component_label_move(self.component_id, position) + + def contextMenuEvent(self, event) -> None: + self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos()) + event.accept() + + class GraphConnectionPointItem(QGraphicsEllipseItem): def __init__(self, connection_id: ConnectionID, index: int, position: tuple[int, int], editor: GraphEditorWidget) -> None: radius = CONNECTION_WIDTH @@ -234,6 +287,7 @@ class GraphEditorWidget(QWidget): component_move_requested = Signal(object, object, object) component_context_menu_requested = Signal(object, object) component_open_requested = Signal(object) + component_label_move_requested = Signal(object, object, object) connection_points_change_requested = Signal(object, object, object, str) connection_add_requested = Signal(object, object) connections_delete_requested = Signal(object, object) @@ -251,6 +305,7 @@ class GraphEditorWidget(QWidget): self._connection_preview: QGraphicsPathItem | None = None self._component_items: dict[ComponentID, GraphComponentItem] = {} self._component_bounds: dict[ComponentID, QRectF] = {} + self._component_label_items: dict[ComponentID, GraphComponentLabelItem] = {} self._connection_items: dict[ConnectionID, GraphConnectionItem] = {} self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {} self.set_snap_to_grid_size(snap_to_grid_size) @@ -310,6 +365,7 @@ class GraphEditorWidget(QWidget): self._icons = icons or {} self._component_items = {} self._component_bounds = {} + self._component_label_items = {} self._connection_items = {} self._connection_point_items = {} self.scene.clear() @@ -331,6 +387,11 @@ class GraphEditorWidget(QWidget): self._component_bounds[component_id] = bounds.translated(-pixmap.width() / 2, -pixmap.height() / 2) self.scene.addItem(item) + for component_id, child in component.implementation.graph.components.items(): + label = graph.component_labels.get(component_id, GraphComponentLabel()) + if label.visible: + self._create_component_label_item(component_id, child.name, label) + for connection_id, connection in component.implementation.graph.connections.items(): if not isinstance(connection, (SignalConnection, BondConnection)): continue @@ -459,6 +520,31 @@ class GraphEditorWidget(QWidget): if self._component is not None: self.component_move_requested.emit(self._component, component_id, position) + def finish_component_label_move(self, component_id: ComponentID, relative_position: tuple[int, int]) -> None: + if self._component is not None: + self.component_label_move_requested.emit(self._component, component_id, relative_position) + + def component_label_visible(self, component_id: ComponentID) -> bool: + return self._graph.component_labels.get(component_id, GraphComponentLabel()).visible + + def set_component_label(self, component_id: ComponentID, label: GraphComponentLabel | None) -> None: + if label is None: + self._graph.component_labels.pop(component_id, None) + label = GraphComponentLabel() + else: + self._graph.component_labels[component_id] = label + item = self._component_label_items.pop(component_id, None) + if item is not None: + item.setParentItem(None) + self.scene.removeItem(item) + component = self._component + if component is not None and label.visible and component_id in component.implementation.graph.components: + self._create_component_label_item(component_id, component.implementation.graph.components[component_id].name, label) + + def _create_component_label_item(self, component_id: ComponentID, text: str, label: GraphComponentLabel) -> None: + item = GraphComponentLabelItem(component_id, text, label, self._component_items[component_id], self) + self._component_label_items[component_id] = item + def set_component_position(self, component_id: ComponentID, position: tuple[int, int] | None) -> None: item = self._component_items.get(component_id) if item is None: diff --git a/untitled.bedit.json b/untitled.bedit.json index 28fcda2..3bbe50a 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -628,6 +628,29 @@ 384 ] }, + "component_labels": { + "43b3aee6-0b38-429b-9c6d-d38fc097297f": { + "relative_position": [ + 68, + -68 + ], + "visible": true + }, + "9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": { + "relative_position": [ + 0, + 8 + ], + "visible": false + }, + "9f392c28-6bef-4b10-9305-93e250747007": { + "relative_position": [ + 0, + 8 + ], + "visible": false + } + }, "connections": { "7e293c73-1e07-4d4c-b750-6f43870c71c4": { "points": [