From 13d3825f9b99bf956a3a290d4ca5e8b185df1632 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 27 Jul 2026 14:12:13 +0200 Subject: [PATCH] Added ports to icon --- .../controllers/document_tree_controller.py | 2 +- src/bedit_gui/models.py | 22 ++++++-- src/bedit_gui/ui/forms/icon_editor_window.ui | 16 ++++++ src/bedit_gui/views/icon_editor_window.py | 49 +++++++++++++++++- src/bedit_gui/views/icon_graphics_scene.py | 50 +++++++++++++++++++ 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index 6d8fe9c..3a2c940 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -113,7 +113,7 @@ class DocumentTreeController(QObject): def _edit_icon(self, component: Component) -> None: component_id = self.document.component_id(component) - editor = IconEditorWindow(self.document.component_icon(component_id), self.window) + editor = IconEditorWindow(self.document.component_icon(component_id), component.interface.ports, self.window) editor.saved.connect(partial(self.document.change_icon, component_id)) editor.destroyed.connect(partial(self._icon_editor_closed, editor)) self._icon_editors.append(editor) diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py index 7ff1ad8..ba517a7 100644 --- a/src/bedit_gui/models.py +++ b/src/bedit_gui/models.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from enum import Enum from typing import Any -from bedit_core.models import ComponentID, ID +from bedit_core.models import ComponentID, ID, PortID class ShapeID(ID): @@ -40,8 +40,8 @@ class LineType(Enum): @dataclass class Rectangle(Shape): type: str = field(init=False, default="rectangle") - width: float = 100.0 - height: float = 100.0 + width: float = 32.0 + height: float = 32.0 line_type: LineType = LineType.SOLID line_thickness: float = 1.0 corner_radius: float = 0.0 @@ -56,18 +56,30 @@ class Rectangle(Shape): def to_data(self) -> dict[str, Any]: return {**super().to_data(), "width": self.width, "height": self.height, "line_type": self.line_type.value, "line_thickness": self.line_thickness, "corner_radius": self.corner_radius, "line_color": self.line_color, "fill_color": self.fill_color} +@dataclass +class Text(Shape): + type: str = field(init=False, default="text") + width: float = 100.0 + height: float = 100.0 + color: str = "#000000ff" + bold: bool = False + italic: bool = False + size: float = 16.0 + text: str = "" @dataclass class Icon: shapes: dict[ShapeID, Shape] = field(default_factory=dict) + port_positions: dict[PortID, tuple[int, int]] = field(default_factory=dict) @classmethod def from_data(cls, data: Mapping[str, Any]) -> Icon: shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()} - return cls(shapes=shapes) + port_positions = {PortID(key): (int(value[0]), int(value[1])) for key, value in data.get("port_positions", {}).items()} + return cls(shapes=shapes, port_positions=port_positions) def to_data(self) -> dict[str, Any]: - return {"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}} + return {"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}, "port_positions": {str(key): list(position) for key, position in self.port_positions.items()}} @dataclass diff --git a/src/bedit_gui/ui/forms/icon_editor_window.ui b/src/bedit_gui/ui/forms/icon_editor_window.ui index b77f8a7..a4b1a30 100644 --- a/src/bedit_gui/ui/forms/icon_editor_window.ui +++ b/src/bedit_gui/ui/forms/icon_editor_window.ui @@ -72,6 +72,7 @@ false + @@ -160,6 +161,21 @@ QAction::MenuRole::NoRole + + + + :/icons/icons/draw-text.png:/icons/icons/draw-text.png + + + Add Text + + + Add a text field + + + QAction::MenuRole::NoRole + + diff --git a/src/bedit_gui/views/icon_editor_window.py b/src/bedit_gui/views/icon_editor_window.py index 3987998..044dc65 100644 --- a/src/bedit_gui/views/icon_editor_window.py +++ b/src/bedit_gui/views/icon_editor_window.py @@ -6,6 +6,7 @@ from PySide6.QtCore import QEvent, QObject, Qt, Signal from PySide6.QtGui import QKeySequence, QPainter, QShortcut, QUndoCommand, QUndoStack, QWheelEvent, QShowEvent from PySide6.QtWidgets import QDialog, QGraphicsView, QMainWindow, QWidget +from bedit_core.models import Port, PortID, SignalDirection from bedit_gui.models import Icon, Shape, ShapeID from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool @@ -69,6 +70,21 @@ class DeleteShapesCommand(QUndoCommand): self.editor._restore_shapes(self.shapes) +class MovePortCommand(QUndoCommand): + def __init__(self, editor: IconEditorWindow, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None: + super().__init__("Move port") + self.editor = editor + self.port_id = port_id + self.old_position = old_position + self.new_position = new_position + + def redo(self) -> None: + self.editor._move_port(self.port_id, self.new_position) + + def undo(self) -> None: + self.editor._move_port(self.port_id, self.old_position) + + class IconEditorWindow(QMainWindow): """Independent icon editing session with its own undo stack.""" @@ -78,7 +94,7 @@ class IconEditorWindow(QMainWindow): minimum_zoom = 0.1 maximum_zoom = 10.0 - def __init__(self, icon: Icon, parent: QWidget | None = None) -> None: + def __init__(self, icon: Icon, ports: dict[PortID, Port], parent: QWidget | None = None) -> None: super().__init__(parent) self.ui = Ui_iconEditor() @@ -88,7 +104,10 @@ class IconEditorWindow(QMainWindow): self._icon = deepcopy(icon) self.scene = IconGraphicsScene(self) + self.scene.set_ports(ports) + self._ensure_port_positions(ports) self.scene.set_icon(self._icon) + self.scene.port_moved.connect(self._port_moved) self.scene.shape_created.connect(self._shape_created) self.scene.shape_changed.connect(self._shape_changed) self.scene.shape_options_requested.connect(self._show_shape_options) @@ -197,12 +216,20 @@ class IconEditorWindow(QMainWindow): self.scene.set_icon(self._icon) self.icon_changed.emit(self.icon()) + def _move_port(self, port_id: PortID, position: tuple[int, int]) -> None: + self._icon.port_positions[port_id] = position + self.scene.set_icon(self._icon) + self.icon_changed.emit(self.icon()) + def _shape_created(self, shape: Shape) -> None: self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape)) def _shape_changed(self, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None: self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape)) + def _port_moved(self, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None: + self.undo_stack.push(MovePortCommand(self, port_id, old_position, new_position)) + def _delete_selected_shapes(self) -> None: shape_ids = self.scene.selected_shape_ids() shapes = {shape_id: self._icon.shapes[shape_id] for shape_id in shape_ids} @@ -219,6 +246,26 @@ class IconEditorWindow(QMainWindow): if new_shape != old_shape: self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape)) + def _ensure_port_positions(self, ports: dict[PortID, Port]) -> None: + bounds = self.scene.sceneRect() + left = round(bounds.left()) + top = round(bounds.top()) + right = round(bounds.right() - 16) + bottom = round(bounds.bottom() - 16) + positions: dict[PortID, tuple[int, int]] = {} + input_index = 0 + output_index = 0 + for port_id, port in ports.items(): + if port.direction is SignalDirection.INPUT: + default = (left, top + input_index * 16) + input_index += 1 + else: + default = (right, top + output_index * 16) + output_index += 1 + position = self._icon.port_positions.get(port_id, default) + positions[port_id] = (max(left, min(right, position[0])), max(top, min(bottom, position[1]))) + self._icon.port_positions = positions + def _start_rectangle_tool(self) -> None: layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1 self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer)) diff --git a/src/bedit_gui/views/icon_graphics_scene.py b/src/bedit_gui/views/icon_graphics_scene.py index f6d02ba..54cf96a 100644 --- a/src/bedit_gui/views/icon_graphics_scene.py +++ b/src/bedit_gui/views/icon_graphics_scene.py @@ -8,6 +8,7 @@ from PySide6.QtCore import QObject, QPointF, QRectF, Qt, Signal from PySide6.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget +from bedit_core.models import Port, PortID, SignalDirection from bedit_gui.models import Icon, LineType, Rectangle, Shape, ShapeID @@ -172,7 +173,48 @@ class RectangleGraphicsItem(ShapeGraphicsItem): self.setPath(path) +class PortGraphicsItem(QGraphicsRectItem): + size = 16.0 + + def __init__(self, port_id: PortID, port: Port, position: tuple[int, int], scene: IconGraphicsScene) -> None: + super().__init__(0, 0, self.size, self.size) + self.port_id = port_id + self.icon_scene = scene + self._original_position: tuple[int, int] | None = None + self.setPos(position[0], position[1]) + self.setPen(QPen(QColor("#000000"))) + self.setBrush(QBrush(QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff"))) + self.setZValue(1000000) + self.setToolTip(port.name) + self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self._original_position = self.position() + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + super().mouseReleaseEvent(event) + position = self.position() + if self._original_position is not None and position != self._original_position: + self.icon_scene.port_moved.emit(self.port_id, self._original_position, position) + self._original_position = None + + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object: + if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None: + position = value + if isinstance(position, QPointF): + bounds = self.icon_scene.sceneRect() + x = max(bounds.left(), min(bounds.right() - self.size, round(position.x()))) + y = max(bounds.top(), min(bounds.bottom() - self.size, round(position.y()))) + return QPointF(x, y) + return super().itemChange(change, value) + + def position(self) -> tuple[int, int]: + return (round(self.pos().x()), round(self.pos().y())) + + class IconGraphicsScene(QGraphicsScene): + port_moved = Signal(object, object, object) shape_created = Signal(object) shape_changed = Signal(object, object, object) shape_options_requested = Signal(object) @@ -181,13 +223,21 @@ class IconGraphicsScene(QGraphicsScene): def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._tool: ShapeCreationTool | None = None + self._ports: dict[PortID, Port] = {} self.setSceneRect(-64, -64, 128, 128) + def set_ports(self, ports: dict[PortID, Port]) -> None: + self._ports = deepcopy(ports) + def set_icon(self, icon: Icon) -> None: self.cancel_creation_tool() self.clear() for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer): self._add_shape_item(shape_id, shape) + for port_id, port in self._ports.items(): + position = icon.port_positions.get(port_id) + if position is not None: + self.addItem(PortGraphicsItem(port_id, port, position, self)) def set_creation_tool(self, tool: ShapeCreationTool) -> None: self.cancel_creation_tool()