Added ports to icon

This commit is contained in:
2026-07-27 14:12:13 +02:00
parent 035cbdf53f
commit 13d3825f9b
5 changed files with 132 additions and 7 deletions

View File

@@ -113,7 +113,7 @@ class DocumentTreeController(QObject):
def _edit_icon(self, component: Component) -> None: def _edit_icon(self, component: Component) -> None:
component_id = self.document.component_id(component) 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.saved.connect(partial(self.document.change_icon, component_id))
editor.destroyed.connect(partial(self._icon_editor_closed, editor)) editor.destroyed.connect(partial(self._icon_editor_closed, editor))
self._icon_editors.append(editor) self._icon_editors.append(editor)

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from bedit_core.models import ComponentID, ID from bedit_core.models import ComponentID, ID, PortID
class ShapeID(ID): class ShapeID(ID):
@@ -40,8 +40,8 @@ class LineType(Enum):
@dataclass @dataclass
class Rectangle(Shape): class Rectangle(Shape):
type: str = field(init=False, default="rectangle") type: str = field(init=False, default="rectangle")
width: float = 100.0 width: float = 32.0
height: float = 100.0 height: float = 32.0
line_type: LineType = LineType.SOLID line_type: LineType = LineType.SOLID
line_thickness: float = 1.0 line_thickness: float = 1.0
corner_radius: float = 0.0 corner_radius: float = 0.0
@@ -56,18 +56,30 @@ class Rectangle(Shape):
def to_data(self) -> dict[str, Any]: 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} 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 @dataclass
class Icon: class Icon:
shapes: dict[ShapeID, Shape] = field(default_factory=dict) shapes: dict[ShapeID, Shape] = field(default_factory=dict)
port_positions: dict[PortID, tuple[int, int]] = field(default_factory=dict)
@classmethod @classmethod
def from_data(cls, data: Mapping[str, Any]) -> Icon: def from_data(cls, data: Mapping[str, Any]) -> Icon:
shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()} 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]: 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 @dataclass

View File

@@ -72,6 +72,7 @@
<bool>false</bool> <bool>false</bool>
</attribute> </attribute>
<addaction name="actionAdd_Rectangle"/> <addaction name="actionAdd_Rectangle"/>
<addaction name="actionAdd_Text"/>
</widget> </widget>
<action name="actionUndo"> <action name="actionUndo">
<property name="icon"> <property name="icon">
@@ -160,6 +161,21 @@
<enum>QAction::MenuRole::NoRole</enum> <enum>QAction::MenuRole::NoRole</enum>
</property> </property>
</action> </action>
<action name="actionAdd_Text">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-text.png</normaloff>:/icons/icons/draw-text.png</iconset>
</property>
<property name="text">
<string>Add Text</string>
</property>
<property name="toolTip">
<string>Add a text field</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget> </widget>
<resources> <resources>
<include location="../../resources/resources.qrc"/> <include location="../../resources/resources.qrc"/>

View File

@@ -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.QtGui import QKeySequence, QPainter, QShortcut, QUndoCommand, QUndoStack, QWheelEvent, QShowEvent
from PySide6.QtWidgets import QDialog, QGraphicsView, QMainWindow, QWidget 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.models import Icon, Shape, ShapeID
from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor
from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool
@@ -69,6 +70,21 @@ class DeleteShapesCommand(QUndoCommand):
self.editor._restore_shapes(self.shapes) 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): class IconEditorWindow(QMainWindow):
"""Independent icon editing session with its own undo stack.""" """Independent icon editing session with its own undo stack."""
@@ -78,7 +94,7 @@ class IconEditorWindow(QMainWindow):
minimum_zoom = 0.1 minimum_zoom = 0.1
maximum_zoom = 10.0 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) super().__init__(parent)
self.ui = Ui_iconEditor() self.ui = Ui_iconEditor()
@@ -88,7 +104,10 @@ class IconEditorWindow(QMainWindow):
self._icon = deepcopy(icon) self._icon = deepcopy(icon)
self.scene = IconGraphicsScene(self) self.scene = IconGraphicsScene(self)
self.scene.set_ports(ports)
self._ensure_port_positions(ports)
self.scene.set_icon(self._icon) 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_created.connect(self._shape_created)
self.scene.shape_changed.connect(self._shape_changed) self.scene.shape_changed.connect(self._shape_changed)
self.scene.shape_options_requested.connect(self._show_shape_options) self.scene.shape_options_requested.connect(self._show_shape_options)
@@ -197,12 +216,20 @@ class IconEditorWindow(QMainWindow):
self.scene.set_icon(self._icon) self.scene.set_icon(self._icon)
self.icon_changed.emit(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: def _shape_created(self, shape: Shape) -> None:
self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape)) self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape))
def _shape_changed(self, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None: 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)) 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: def _delete_selected_shapes(self) -> None:
shape_ids = self.scene.selected_shape_ids() shape_ids = self.scene.selected_shape_ids()
shapes = {shape_id: self._icon.shapes[shape_id] for shape_id in 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: if new_shape != old_shape:
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_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: def _start_rectangle_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1 layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer)) self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer))

View File

@@ -8,6 +8,7 @@ from PySide6.QtCore import QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen from PySide6.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget 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 from bedit_gui.models import Icon, LineType, Rectangle, Shape, ShapeID
@@ -172,7 +173,48 @@ class RectangleGraphicsItem(ShapeGraphicsItem):
self.setPath(path) 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): class IconGraphicsScene(QGraphicsScene):
port_moved = Signal(object, object, object)
shape_created = Signal(object) shape_created = Signal(object)
shape_changed = Signal(object, object, object) shape_changed = Signal(object, object, object)
shape_options_requested = Signal(object) shape_options_requested = Signal(object)
@@ -181,13 +223,21 @@ class IconGraphicsScene(QGraphicsScene):
def __init__(self, parent: QObject | None = None) -> None: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._tool: ShapeCreationTool | None = None self._tool: ShapeCreationTool | None = None
self._ports: dict[PortID, Port] = {}
self.setSceneRect(-64, -64, 128, 128) 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: def set_icon(self, icon: Icon) -> None:
self.cancel_creation_tool() self.cancel_creation_tool()
self.clear() self.clear()
for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer): for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer):
self._add_shape_item(shape_id, shape) 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: def set_creation_tool(self, tool: ShapeCreationTool) -> None:
self.cancel_creation_tool() self.cancel_creation_tool()