Better drawing and added more icons

This commit is contained in:
2026-07-20 13:18:16 +02:00
parent ee859c4311
commit 4ab74f64e2
27 changed files with 4087 additions and 307 deletions

View File

@@ -1,7 +1,7 @@
from PySide6.QtCore import QPointF
from PySide6.QtGui import QUndoCommand
from bedit.core.model import Component, Connection, Port
from bedit.core.model import Annotation, Component, Connection, Port
class AddComponentCommand(QUndoCommand):
@@ -76,6 +76,55 @@ class AddConnectionCommand(QUndoCommand):
self.controller._remove_connection(self.owner_id, self.connection.id)
class AddAnnotationCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, annotation: Annotation) -> None:
super().__init__(f"Draw {annotation.kind}")
self.controller, self.owner_id, self.annotation = controller, owner_id, annotation
def redo(self) -> None:
self.controller._insert_annotation(self.owner_id, self.annotation)
def undo(self) -> None:
self.controller._remove_annotation(self.owner_id, self.annotation.id)
class EditGraphItemCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
item_kind: str,
item_id: str,
old: dict,
new: dict,
text: str,
) -> None:
super().__init__(text)
self.controller, self.owner_id = controller, owner_id
self.item_kind, self.item_id = item_kind, item_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._set_graph_item_data(self.owner_id, self.item_kind, self.item_id, self.new)
def undo(self) -> None:
self.controller._set_graph_item_data(self.owner_id, self.item_kind, self.item_id, self.old)
class DeleteAnnotationsCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, annotations: dict[str, Annotation]) -> None:
super().__init__("Delete annotations")
self.controller, self.owner_id, self.annotations = controller, owner_id, annotations
def redo(self) -> None:
for annotation_id in self.annotations:
self.controller._remove_annotation(self.owner_id, annotation_id)
def undo(self) -> None:
for annotation in self.annotations.values():
self.controller._insert_annotation(self.owner_id, annotation)
class ReplaceComponentCommand(QUndoCommand):
def __init__(self, controller, old: Component, new: Component) -> None:
super().__init__("Apply JSON changes")

View File

@@ -8,8 +8,11 @@ from PySide6.QtGui import QUndoStack
from bedit.gui.controllers.commands import (
AddComponentCommand,
AddConnectionCommand,
AddAnnotationCommand,
AddInterfacePortCommand,
DeleteSelectionCommand,
DeleteAnnotationsCommand,
EditGraphItemCommand,
EditTextDefinitionCommand,
EditComponentAppearanceCommand,
MoveComponentCommand,
@@ -21,6 +24,7 @@ from bedit.gui.controllers.commands import (
RotateComponentsCommand,
)
from bedit.core.model import (
Annotation,
Component,
Connection,
Endpoint,
@@ -43,6 +47,9 @@ class DocumentController(QObject):
componentRotated = Signal(str, float)
connectionAdded = Signal(str)
connectionRemoved = Signal(str)
graphItemChanged = Signal(str, str)
annotationAdded = Signal(str)
annotationRemoved = Signal(str)
interfaceChanged = Signal()
filePathChanged = Signal(object)
modifiedChanged = Signal(bool)
@@ -221,16 +228,14 @@ class DocumentController(QObject):
if (component := self.active_component.graph.blocks.get(component_id)) is not None
}
if rotations:
self.undo_stack.push(
RotateComponentsCommand(self, self.active_component_id, rotations)
)
self.undo_stack.push(RotateComponentsCommand(self, self.active_component_id, rotations))
def connect(
self,
source: Endpoint,
target: Endpoint,
*,
routing: str = "spline",
routing: str = "angled",
waypoints: list[QPointF] | None = None,
) -> str:
if self.active_component_id is None:
@@ -240,9 +245,7 @@ class DocumentController(QObject):
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}"
)
raise ValueError(f"Cannot connect {source_port.type!r} to {target_port.type!r}")
if routing not in {"direct", "angled", "spline"}:
raise ValueError(f"Unknown connection routing: {routing}")
connection = Connection(
@@ -251,16 +254,154 @@ class DocumentController(QObject):
target,
properties={
"routing": routing,
"waypoints": [
{"x": point.x(), "y": point.y()} for point in (waypoints or [])
],
"waypoints": [{"x": point.x(), "y": point.y()} for point in (waypoints or [])],
},
)
self.undo_stack.push(
AddConnectionCommand(self, self.active_component_id, connection)
)
self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection))
return connection.id
def add_annotation(
self,
kind: str,
start: QPointF,
end: QPointF,
*,
text: str = "",
routing: str = "angled",
waypoints: list[QPointF] | None = None,
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
if kind != "line":
left, right = sorted((start.x(), end.x()))
top, bottom = sorted((start.y(), end.y()))
start, end = QPointF(left, top), QPointF(right, bottom)
style = {
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"fill": "none" if kind in {"line", "text"} else "#dbeafe",
}
if kind == "box":
style["cornerRadius"] = 0.0
if kind == "text":
style.update({"fontSize": 12.0, "color": "#202020"})
annotation = Annotation(
id=str(uuid4()),
kind=kind,
x=start.x(),
y=start.y(),
width=end.x() - start.x(),
height=end.y() - start.y(),
text=text,
layer=-1,
properties={
**style,
"routing": routing,
"waypoints": [{"x": point.x(), "y": point.y()} for point in (waypoints or [])],
},
)
self.undo_stack.push(AddAnnotationCommand(self, self.active_component_id, annotation))
return annotation.id
def set_route_waypoints(
self, item_kind: str, item_id: str, waypoints: list[QPointF], routing: str | None = None
) -> None:
item = (
self.active_graph.connections
if item_kind == "connection"
else self.active_graph.annotations
).get(item_id)
if item is None or self.active_component_id is None:
return
old = deepcopy(item.properties)
new = deepcopy(old)
if routing is not None:
new["routing"] = routing
new["waypoints"] = [{"x": point.x(), "y": point.y()} for point in waypoints]
if old != new:
self.undo_stack.push(
EditGraphItemCommand(
self, self.active_component_id, item_kind, item_id, old, new, "Edit line nodes"
)
)
def set_annotation_geometry(self, annotation_id: str, old: dict, new: dict) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"annotation_geometry",
annotation_id,
old,
new,
"Move annotation",
)
)
def edit_annotation(self, annotation_id: str, values: dict) -> None:
annotation = self.active_graph.annotations.get(annotation_id)
if annotation is None or self.active_component_id is None:
return
old = annotation.to_dict()
if old != values:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"annotation_data",
annotation_id,
old,
deepcopy(values),
"Edit shape",
)
)
def reorder_annotations(self, annotation_ids: set[str], operation: str) -> None:
if not annotation_ids or self.active_component_id is None:
return
graph = self.active_graph
layers = [item.layer for item in graph.annotations.values()]
minimum, maximum = min(layers, default=-1), max(layers, default=1)
for annotation_id in annotation_ids:
item = graph.annotations.get(annotation_id)
if item is None:
continue
old = {"layer": item.layer}
forward = item.layer + 1
backward = item.layer - 1
if forward == 0:
forward = 1
if backward == 0:
backward = -1
layer = {
"forward": forward,
"backward": backward,
"front": max(1, maximum + 1),
"back": min(-1, minimum - 1),
}[operation]
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"annotation_layer",
annotation_id,
old,
{"layer": layer},
"Reorder annotation",
)
)
def delete_annotations(self, annotation_ids: set[str]) -> None:
items = {
key: self.active_graph.annotations[key]
for key in annotation_ids
if key in self.active_graph.annotations
}
if items and self.active_component_id is not None:
self.undo_stack.push(DeleteAnnotationsCommand(self, self.active_component_id, items))
def _port_for_endpoint(self, endpoint: Endpoint, role: str) -> Port | None:
owner = self.active_component
if owner is None:
@@ -272,7 +413,9 @@ class DocumentController(QObject):
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)
return next(
(port for port in ports if port.id == (endpoint.interface or endpoint.port)), None
)
def connection_port_type(self, connection: Connection) -> str:
port = self._port_for_endpoint(connection.source, "source")
@@ -289,9 +432,7 @@ class DocumentController(QObject):
x=position.x(),
y=position.y(),
)
self.undo_stack.push(
AddInterfacePortCommand(self, component.id, direction, port)
)
self.undo_stack.push(AddInterfacePortCommand(self, component.id, direction, port))
return port.id
def move_interface_port(self, port_id: str, old: QPointF, new: QPointF) -> None:
@@ -350,11 +491,17 @@ class DocumentController(QObject):
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:
if (
connection.target.block == component.id
and connection.target.port not in input_ids
):
raise ValueError(
f"Input {connection.target.port!r} is still connected in the containing graph"
)
if connection.source.block == component.id and connection.source.port not in output_ids:
if (
connection.source.block == component.id
and connection.source.port not in output_ids
):
raise ValueError(
f"Output {connection.source.port!r} is still connected in the containing graph"
)
@@ -422,9 +569,15 @@ class DocumentController(QObject):
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:
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:
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:
@@ -471,7 +624,9 @@ class DocumentController(QObject):
or connection.target.interface in output_ids
):
all_connection_ids.add(connection.id)
blocks = {block_id: graph.blocks[block_id] for block_id in block_ids if block_id in graph.blocks}
blocks = {
block_id: graph.blocks[block_id] for block_id in block_ids if block_id in graph.blocks
}
connections = {
connection_id: graph.connections[connection_id]
for connection_id in all_connection_ids
@@ -526,9 +681,7 @@ class DocumentController(QObject):
)
connections[connection.id] = connection
if blocks:
self.undo_stack.push(
PasteSelectionCommand(self, owner.id, blocks, connections)
)
self.undo_stack.push(PasteSelectionCommand(self, owner.id, blocks, connections))
return list(blocks)
def _graph_for(self, owner_id: str):
@@ -571,9 +724,7 @@ class DocumentController(QObject):
self.componentMoved.emit(component_id, position)
self.documentReset.emit()
def _rotate_component(
self, owner_id: str, component_id: str, rotation: float
) -> None:
def _rotate_component(self, owner_id: str, component_id: str, rotation: float) -> None:
component = self._graph_for(owner_id).blocks.get(component_id)
if component is None:
return
@@ -593,6 +744,46 @@ class DocumentController(QObject):
self.connectionRemoved.emit(connection_id)
self.documentReset.emit()
def _insert_annotation(self, owner_id: str, annotation: Annotation) -> None:
self._graph_for(owner_id).annotations[annotation.id] = annotation
if owner_id == self.active_component_id:
self.annotationAdded.emit(annotation.id)
self.documentReset.emit()
def _remove_annotation(self, owner_id: str, annotation_id: str) -> None:
self._graph_for(owner_id).annotations.pop(annotation_id, None)
if owner_id == self.active_component_id:
self.annotationRemoved.emit(annotation_id)
self.documentReset.emit()
def _set_graph_item_data(
self, owner_id: str, item_kind: str, item_id: str, values: dict
) -> None:
graph = self._graph_for(owner_id)
if item_kind == "connection":
item = graph.connections.get(item_id)
if item is not None:
item.properties = deepcopy(values)
else:
item = graph.annotations.get(item_id)
if item is not None:
if item_kind == "annotation_geometry":
item.x, item.y = float(values["x"]), float(values["y"])
item.width, item.height = float(values["width"]), float(values["height"])
elif item_kind == "annotation_layer":
item.layer = int(values["layer"])
elif item_kind == "annotation_data":
replacement = Annotation.from_dict(values)
item.kind = replacement.kind
item.x, item.y = replacement.x, replacement.y
item.width, item.height = replacement.width, replacement.height
item.text, item.layer = replacement.text, replacement.layer
item.properties = replacement.properties
else:
item.properties = deepcopy(values)
if owner_id == self.active_component_id:
self.graphItemChanged.emit(item_kind, item_id)
def _replace_component(self, old_id: str, replacement: Component) -> None:
if self.document is None:
return

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,13 @@ class Ui_IconEditorDialog(object):
self.dialogLayout.setObjectName(u"dialogLayout")
self.shapeToolbarLayout = QHBoxLayout()
self.shapeToolbarLayout.setObjectName(u"shapeToolbarLayout")
self.pointerButton = QToolButton(IconEditorDialog)
self.pointerButton.setObjectName(u"pointerButton")
self.pointerButton.setCheckable(True)
self.pointerButton.setChecked(True)
self.shapeToolbarLayout.addWidget(self.pointerButton)
self.addShapeLabel = QLabel(IconEditorDialog)
self.addShapeLabel.setObjectName(u"addShapeLabel")
@@ -38,31 +45,37 @@ class Ui_IconEditorDialog(object):
self.addRectangleButton = QToolButton(IconEditorDialog)
self.addRectangleButton.setObjectName(u"addRectangleButton")
self.addRectangleButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addRectangleButton)
self.addCircleButton = QToolButton(IconEditorDialog)
self.addCircleButton.setObjectName(u"addCircleButton")
self.addCircleButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addCircleButton)
self.addEllipseButton = QToolButton(IconEditorDialog)
self.addEllipseButton.setObjectName(u"addEllipseButton")
self.addEllipseButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addEllipseButton)
self.addLineButton = QToolButton(IconEditorDialog)
self.addLineButton.setObjectName(u"addLineButton")
self.addLineButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addLineButton)
self.addTriangleButton = QToolButton(IconEditorDialog)
self.addTriangleButton.setObjectName(u"addTriangleButton")
self.addTriangleButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addTriangleButton)
self.addTextButton = QToolButton(IconEditorDialog)
self.addTextButton.setObjectName(u"addTextButton")
self.addTextButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addTextButton)
@@ -70,6 +83,21 @@ class Ui_IconEditorDialog(object):
self.shapeToolbarLayout.addItem(self.toolbarSpacer)
self.zoomInButton = QToolButton(IconEditorDialog)
self.zoomInButton.setObjectName(u"zoomInButton")
self.shapeToolbarLayout.addWidget(self.zoomInButton)
self.zoomOutButton = QToolButton(IconEditorDialog)
self.zoomOutButton.setObjectName(u"zoomOutButton")
self.shapeToolbarLayout.addWidget(self.zoomOutButton)
self.centerButton = QToolButton(IconEditorDialog)
self.centerButton.setObjectName(u"centerButton")
self.shapeToolbarLayout.addWidget(self.centerButton)
self.deleteSelectedButton = QPushButton(IconEditorDialog)
self.deleteSelectedButton.setObjectName(u"deleteSelectedButton")
@@ -106,6 +134,7 @@ class Ui_IconEditorDialog(object):
def retranslateUi(self, IconEditorDialog):
IconEditorDialog.setWindowTitle(QCoreApplication.translate("IconEditorDialog", u"Icon Editor", None))
self.pointerButton.setText(QCoreApplication.translate("IconEditorDialog", u"Pointer", None))
self.addShapeLabel.setText(QCoreApplication.translate("IconEditorDialog", u"Add:", None))
self.addRectangleButton.setText(QCoreApplication.translate("IconEditorDialog", u"Rectangle", None))
self.addCircleButton.setText(QCoreApplication.translate("IconEditorDialog", u"Circle", None))
@@ -113,6 +142,18 @@ class Ui_IconEditorDialog(object):
self.addLineButton.setText(QCoreApplication.translate("IconEditorDialog", u"Line", None))
self.addTriangleButton.setText(QCoreApplication.translate("IconEditorDialog", u"Triangle", None))
self.addTextButton.setText(QCoreApplication.translate("IconEditorDialog", u"Text", None))
self.zoomInButton.setText(QCoreApplication.translate("IconEditorDialog", u"+", None))
#if QT_CONFIG(tooltip)
self.zoomInButton.setToolTip(QCoreApplication.translate("IconEditorDialog", u"Zoom in", None))
#endif // QT_CONFIG(tooltip)
self.zoomOutButton.setText(QCoreApplication.translate("IconEditorDialog", u"\u2212", None))
#if QT_CONFIG(tooltip)
self.zoomOutButton.setToolTip(QCoreApplication.translate("IconEditorDialog", u"Zoom out", None))
#endif // QT_CONFIG(tooltip)
self.centerButton.setText(QCoreApplication.translate("IconEditorDialog", u"Fit", None))
#if QT_CONFIG(tooltip)
self.centerButton.setToolTip(QCoreApplication.translate("IconEditorDialog", u"Fit and center the icon canvas", None))
#endif // QT_CONFIG(tooltip)
self.deleteSelectedButton.setText(QCoreApplication.translate("IconEditorDialog", u"Delete selected", None))
self.portHintLabel.setText(QCoreApplication.translate("IconEditorDialog", u"Green points are inputs; red points are outputs. Drag them to place connection anchors.", None))
# retranslateUi

View File

@@ -29,7 +29,7 @@ class Ui_MainWindow(object):
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(1000, 700)
MainWindow.resize(1209, 777)
self.actionNew = QAction(MainWindow)
self.actionNew.setObjectName(u"actionNew")
icon = QIcon()
@@ -42,54 +42,63 @@ class Ui_MainWindow(object):
self.actionRotateClockwise.setIcon(icon1)
self.actionZoomIn = QAction(MainWindow)
self.actionZoomIn.setObjectName(u"actionZoomIn")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/zoom-in.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomIn.setIcon(icon2)
self.actionZoomOut = QAction(MainWindow)
self.actionZoomOut.setObjectName(u"actionZoomOut")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/zoom-out.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomOut.setIcon(icon3)
self.actionCenterView = QAction(MainWindow)
self.actionCenterView.setObjectName(u"actionCenterView")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCenterView.setIcon(icon4)
self.actionOpen = QAction(MainWindow)
self.actionOpen.setObjectName(u"actionOpen")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon2)
icon5 = QIcon()
icon5.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon5)
self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon3)
icon6 = QIcon()
icon6.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon6)
self.actionSaveAs = QAction(MainWindow)
self.actionSaveAs.setObjectName(u"actionSaveAs")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon4)
icon7 = QIcon()
icon7.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon7)
self.actionExit = QAction(MainWindow)
self.actionExit.setObjectName(u"actionExit")
self.actionClose = QAction(MainWindow)
self.actionClose.setObjectName(u"actionClose")
self.actionUndo = QAction(MainWindow)
self.actionUndo.setObjectName(u"actionUndo")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon5)
icon8 = QIcon()
icon8.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon8)
self.actionRedo = QAction(MainWindow)
self.actionRedo.setObjectName(u"actionRedo")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon6)
icon9 = QIcon()
icon9.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon9)
self.actionCut = QAction(MainWindow)
self.actionCut.setObjectName(u"actionCut")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon7)
icon10 = QIcon()
icon10.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon10)
self.actionCopy = QAction(MainWindow)
self.actionCopy.setObjectName(u"actionCopy")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon8)
icon11 = QIcon()
icon11.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon11)
self.actionPaste = QAction(MainWindow)
self.actionPaste.setObjectName(u"actionPaste")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon9)
icon12 = QIcon()
icon12.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon12)
self.actionSelectAll = QAction(MainWindow)
self.actionSelectAll.setObjectName(u"actionSelectAll")
self.actionDelete = QAction(MainWindow)
@@ -129,9 +138,9 @@ class Ui_MainWindow(object):
self.librariesLayout.setContentsMargins(0, 0, 0, 0)
self.treeView = QTreeView(self.dockWidgetContents)
self.treeView.setObjectName(u"treeView")
self.treeView.setIconSize(QSize(28, 28))
self.treeView.setStyleSheet(u"QTreeView::item { height: 32px; }")
self.treeView.setAlternatingRowColors(True)
self.treeView.setIconSize(QSize(28, 28))
self.treeView.setUniformRowHeights(True)
self.librariesLayout.addWidget(self.treeView)
@@ -149,8 +158,8 @@ class Ui_MainWindow(object):
self.documentPanelLayout.setContentsMargins(0, 0, 0, 0)
self.documentTreeView = QTreeView(self.documentDockContents)
self.documentTreeView.setObjectName(u"documentTreeView")
self.documentTreeView.setIconSize(QSize(16, 16))
self.documentTreeView.setAlternatingRowColors(True)
self.documentTreeView.setIconSize(QSize(16, 16))
self.documentTreeView.setUniformRowHeights(True)
self.documentPanelLayout.addWidget(self.documentTreeView)
@@ -179,12 +188,18 @@ class Ui_MainWindow(object):
self.workspaceHeaderLayout.setContentsMargins(6, 2, 6, 2)
self.navigateUpButton = QToolButton(self.workspaceHeader)
self.navigateUpButton.setObjectName(u"navigateUpButton")
icon13 = QIcon()
icon13.addFile(u":/icons/icons/arrow-up.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.navigateUpButton.setIcon(icon13)
self.workspaceHeaderLayout.addWidget(self.navigateUpButton)
self.navigateDownButton = QToolButton(self.workspaceHeader)
self.navigateDownButton.setObjectName(u"navigateDownButton")
self.navigateDownButton.setEnabled(False)
icon14 = QIcon()
icon14.addFile(u":/icons/icons/arrow-down.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.navigateDownButton.setIcon(icon14)
self.workspaceHeaderLayout.addWidget(self.navigateDownButton)
@@ -210,6 +225,9 @@ class Ui_MainWindow(object):
self.pointerToolButton = QToolButton(self.workspaceHeader)
self.pointerToolButton.setObjectName(u"pointerToolButton")
icon15 = QIcon()
icon15.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.pointerToolButton.setIcon(icon15)
self.pointerToolButton.setCheckable(True)
self.pointerToolButton.setChecked(True)
@@ -221,6 +239,39 @@ class Ui_MainWindow(object):
self.workspaceHeaderLayout.addWidget(self.connectToolButton)
self.boxToolButton = QToolButton(self.workspaceHeader)
self.boxToolButton.setObjectName(u"boxToolButton")
icon16 = QIcon()
icon16.addFile(u":/icons/icons/draw-rectangle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.boxToolButton.setIcon(icon16)
self.boxToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.boxToolButton)
self.lineToolButton = QToolButton(self.workspaceHeader)
self.lineToolButton.setObjectName(u"lineToolButton")
icon17 = QIcon()
icon17.addFile(u":/icons/icons/draw-bezier-curves.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.lineToolButton.setIcon(icon17)
self.lineToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.lineToolButton)
self.textToolButton = QToolButton(self.workspaceHeader)
self.textToolButton.setObjectName(u"textToolButton")
icon18 = QIcon()
icon18.addFile(u":/icons/icons/draw-text.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.textToolButton.setIcon(icon18)
self.textToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.textToolButton)
self.rotateToolButton = QToolButton(self.workspaceHeader)
self.rotateToolButton.setObjectName(u"rotateToolButton")
self.rotateToolButton.setIcon(icon1)
self.workspaceHeaderLayout.addWidget(self.rotateToolButton)
self.routingLabel = QLabel(self.workspaceHeader)
self.routingLabel.setObjectName(u"routingLabel")
@@ -235,13 +286,13 @@ class Ui_MainWindow(object):
self.angledRoutingButton = QToolButton(self.workspaceHeader)
self.angledRoutingButton.setObjectName(u"angledRoutingButton")
self.angledRoutingButton.setCheckable(True)
self.angledRoutingButton.setChecked(True)
self.workspaceHeaderLayout.addWidget(self.angledRoutingButton)
self.splineRoutingButton = QToolButton(self.workspaceHeader)
self.splineRoutingButton.setObjectName(u"splineRoutingButton")
self.splineRoutingButton.setCheckable(True)
self.splineRoutingButton.setChecked(True)
self.workspaceHeaderLayout.addWidget(self.splineRoutingButton)
@@ -296,7 +347,7 @@ class Ui_MainWindow(object):
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 1000, 24))
self.menubar.setGeometry(QRect(0, 0, 1209, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuEdit = QMenu(self.menubar)
@@ -324,9 +375,6 @@ class Ui_MainWindow(object):
self.editToolbar = QToolBar(MainWindow)
self.editToolbar.setObjectName(u"editToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.editToolbar)
self.transformToolbar = QToolBar(MainWindow)
self.transformToolbar.setObjectName(u"transformToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.transformToolbar)
self.cameraToolbar = QToolBar(MainWindow)
self.cameraToolbar.setObjectName(u"cameraToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.cameraToolbar)
@@ -366,7 +414,6 @@ class Ui_MainWindow(object):
self.editToolbar.addAction(self.actionCopy)
self.editToolbar.addAction(self.actionCut)
self.editToolbar.addAction(self.actionPaste)
self.transformToolbar.addAction(self.actionRotateClockwise)
self.cameraToolbar.addAction(self.actionZoomIn)
self.cameraToolbar.addAction(self.actionZoomOut)
self.cameraToolbar.addAction(self.actionCenterView)
@@ -469,19 +516,35 @@ class Ui_MainWindow(object):
self.actionAboutQt.setText(QCoreApplication.translate("MainWindow", u"About &Qt", None))
self.panel_libraries.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", None))
self.panel_document.setWindowTitle(QCoreApplication.translate("MainWindow", u"Document", None))
self.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None))
#if QT_CONFIG(tooltip)
self.navigateUpButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the containing graph", None))
#endif // QT_CONFIG(tooltip)
self.navigateDownButton.setText(QCoreApplication.translate("MainWindow", u"Down", None))
self.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None))
#if QT_CONFIG(tooltip)
self.navigateDownButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the selected block", None))
#endif // QT_CONFIG(tooltip)
self.navigateDownButton.setText(QCoreApplication.translate("MainWindow", u"Down", None))
self.graphBreadcrumbLabel.setText(QCoreApplication.translate("MainWindow", u"Untitled", None))
self.workspaceModeLabel.setText(QCoreApplication.translate("MainWindow", u"Graph", None))
self.applyJsonButton.setText(QCoreApplication.translate("MainWindow", u"Apply JSON", None))
self.pointerToolButton.setText(QCoreApplication.translate("MainWindow", u"Pointer", None))
self.connectToolButton.setText(QCoreApplication.translate("MainWindow", u"Connect", None))
#if QT_CONFIG(tooltip)
self.boxToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Draw a box annotation", None))
#endif // QT_CONFIG(tooltip)
self.boxToolButton.setText(QCoreApplication.translate("MainWindow", u"Box", None))
#if QT_CONFIG(tooltip)
self.lineToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Draw a line annotation", None))
#endif // QT_CONFIG(tooltip)
self.lineToolButton.setText(QCoreApplication.translate("MainWindow", u"Line", None))
#if QT_CONFIG(tooltip)
self.textToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add a text annotation", None))
#endif // QT_CONFIG(tooltip)
self.textToolButton.setText(QCoreApplication.translate("MainWindow", u"Text", None))
#if QT_CONFIG(tooltip)
self.rotateToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise", None))
#endif // QT_CONFIG(tooltip)
self.rotateToolButton.setText(QCoreApplication.translate("MainWindow", u"Rotate", None))
self.routingLabel.setText(QCoreApplication.translate("MainWindow", u"Line:", None))
self.directRoutingButton.setText(QCoreApplication.translate("MainWindow", u"Direct", None))
self.angledRoutingButton.setText(QCoreApplication.translate("MainWindow", u"Angled", None))
@@ -496,7 +559,6 @@ class Ui_MainWindow(object):
self.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"&Help", None))
self.fileToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"File", None))
self.editToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Edit", None))
self.transformToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Transform", None))
self.cameraToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Camera", None))
# retranslateUi

View File

@@ -1,5 +1,6 @@
from PySide6.QtCore import QPointF, QRectF
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsView
from bedit.gui.preferences import application_settings
@@ -22,3 +23,25 @@ class IconCanvasView(QGraphicsView):
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
for y in range(top, int(rect.bottom()) + grid, grid):
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
def _zoom(self, factor: float) -> None:
target = self.transform().m11() * factor
if 0.25 <= target <= 16.0:
self.scale(factor, factor)
def zoom_in(self) -> None:
self._zoom(1.2)
def zoom_out(self) -> None:
self._zoom(1 / 1.2)
def center_icon(self) -> None:
if self.scene() is not None:
self.fitInView(
self.scene().sceneRect().adjusted(-10, -10, 10, 10),
Qt.AspectRatioMode.KeepAspectRatio,
)
def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
self._zoom(1.2 if event.angleDelta().y() > 0 else 1 / 1.2)
event.accept()

View File

@@ -1,13 +1,15 @@
from copy import deepcopy
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF
from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import (
QColorDialog,
QButtonGroup,
QDialog,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
@@ -19,7 +21,7 @@ from bedit.core.model import Component, Icon, Port
from bedit.gui.generated.ui_icon_editor_dialog import Ui_IconEditorDialog
from bedit.gui.generated.ui_shape_options_dialog import Ui_ShapeOptionsDialog
from bedit.gui.graphics.icon_canvas import icon_grid_size
from bedit.gui.graphics.icon_renderer import _pen
from bedit.gui.graphics.icon_renderer import shape_pen
def _icon_grid_size() -> int:
@@ -49,6 +51,21 @@ class ResizeHandle(QGraphicsEllipseItem):
if self.owner.scene()
else QPointF(128, 128)
)
if self.owner.element.get("type") == "line":
minimum = (
self.owner.scene().sceneRect().topLeft() - self.owner.pos()
if self.owner.scene()
else QPointF(-128, -128)
)
value = QPointF(
min(maximum.x(), max(minimum.x(), _snap(value.x()))),
min(maximum.y(), max(minimum.y(), _snap(value.y()))),
)
self.owner.prepareGeometryChange()
self.owner.element["width"] = value.x()
self.owner.element["height"] = value.y()
self.owner.update()
return super().itemChange(change, value)
value = QPointF(
min(maximum.x(), max(_icon_grid_size(), _snap(value.x()))),
min(maximum.y(), max(_icon_grid_size(), _snap(value.y()))),
@@ -82,9 +99,17 @@ class ShapeOptionsDialog(QDialog):
self.ui.cornerRadiusSpin.setValue(float(self.element.get("cornerRadius", 0)))
self.ui.textEdit.setText(str(self.element.get("text", "Text")))
self.ui.fontSizeSpin.setValue(float(self.element.get("fontSize", 12)))
self._show_row(self.ui.fillTypeLabel, self.ui.fillTypeCombo, self.element.get("type") != "line")
self._show_row(self.ui.fillColorLabel, self.ui.fillColorButton, self.element.get("type") != "line")
self._show_row(self.ui.cornerRadiusLabel, self.ui.cornerRadiusSpin, self.element.get("type") == "rectangle")
self._show_row(
self.ui.fillTypeLabel, self.ui.fillTypeCombo, self.element.get("type") != "line"
)
self._show_row(
self.ui.fillColorLabel, self.ui.fillColorButton, self.element.get("type") != "line"
)
self._show_row(
self.ui.cornerRadiusLabel,
self.ui.cornerRadiusSpin,
self.element.get("type") == "rectangle",
)
is_text = self.element.get("type") == "text"
self._show_row(self.ui.textLabel, self.ui.textEdit, is_text)
self._show_row(self.ui.fontSizeLabel, self.ui.fontSizeSpin, is_text)
@@ -99,7 +124,9 @@ class ShapeOptionsDialog(QDialog):
def _choose_color(self, current: str) -> str:
color = QColorDialog.getColor(
QColor(current), self, "Choose colour",
QColor(current),
self,
"Choose colour",
QColorDialog.ColorDialogOption.ShowAlphaChannel,
)
if not color.isValid():
@@ -129,7 +156,9 @@ class ShapeOptionsDialog(QDialog):
self.element["width"] = self.ui.widthSpin.value()
self.element["height"] = self.ui.heightSpin.value()
if self.element.get("type") != "line":
self.element["fill"] = self.fill_color if self.ui.fillTypeCombo.currentText() == "solid" else "none"
self.element["fill"] = (
self.fill_color if self.ui.fillTypeCombo.currentText() == "solid" else "none"
)
if self.element.get("type") == "rectangle":
self.element["cornerRadius"] = self.ui.cornerRadiusSpin.value()
if self.element.get("type") == "text":
@@ -144,21 +173,35 @@ class ShapeItem(QGraphicsObject):
super().__init__()
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.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)))
return QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))).adjusted(-margin, -margin, margin, margin)
return (
QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
.normalized()
.adjusted(-margin, -margin, margin, margin)
)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20)))
painter.setPen(_pen(self.element))
rect = QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
painter.setPen(shape_pen(self.element))
fill = self.element.get("fill", "none")
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
kind = self.element.get("type")
if kind == "rectangle":
radius = float(self.element.get("cornerRadius", 0))
@@ -168,13 +211,17 @@ class ShapeItem(QGraphicsObject):
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()]))
painter.drawPolygon(
QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()])
)
elif kind == "text":
painter.setPen(QColor(self.element.get("color", "#202020")))
font = painter.font()
font.setPointSizeF(float(self.element.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text")))
painter.drawText(
rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text"))
)
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
@@ -182,23 +229,7 @@ class ShapeItem(QGraphicsObject):
def itemChange(self, change, value): # noqa: N802
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()),
),
),
)
value = self._bounded_position(value)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.element["x"], self.element["y"] = value.x(), value.y()
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
@@ -207,10 +238,17 @@ class ShapeItem(QGraphicsObject):
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.setPos(self._bounded_position(self.pos()))
def _bounded_position(self, position: QPointF) -> QPointF:
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()))),
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
minimum_x, maximum_x = min(0.0, width), max(0.0, width)
minimum_y, maximum_y = min(0.0, height), max(0.0, height)
return QPointF(
max(bounds.left() - minimum_x, min(bounds.right() - maximum_x, _snap(position.x()))),
max(bounds.top() - minimum_y, min(bounds.bottom() - maximum_y, _snap(position.y()))),
)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
@@ -219,11 +257,19 @@ class ShapeItem(QGraphicsObject):
delete = menu.addAction("Delete Shape")
chosen = menu.exec(event.screenPos())
if chosen is options:
dialog = ShapeOptionsDialog(self.element)
options_element = deepcopy(self.element)
width_sign = -1 if float(options_element.get("width", 20)) < 0 else 1
height_sign = -1 if float(options_element.get("height", 20)) < 0 else 1
options_element["width"] = abs(float(options_element.get("width", 20)))
options_element["height"] = abs(float(options_element.get("height", 20)))
dialog = ShapeOptionsDialog(options_element)
if dialog.exec() == dialog.DialogCode.Accepted:
self.prepareGeometryChange()
self.element.clear()
self.element.update(dialog.element)
if self.element.get("type") == "line":
self.element["width"] *= width_sign
self.element["height"] *= height_sign
self.resize_handle.setPos(
float(self.element.get("width", 20)),
float(self.element.get("height", 20)),
@@ -244,7 +290,11 @@ class PortHandle(QGraphicsEllipseItem):
self.setPen(QPen(QColor("#ffffff"), 1.5))
self.setToolTip(f"{direction.title()}: {port.name} (drag to position)")
self.setZValue(10)
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
@@ -266,6 +316,81 @@ class PortHandle(QGraphicsEllipseItem):
)
class IconDrawingScene(QGraphicsScene):
def __init__(self, editor: "IconEditorDialog", rect: QRectF) -> None:
super().__init__(rect, editor)
self.editor = editor
self.mode = "pointer"
self.start: QPointF | None = None
self.preview: QGraphicsPathItem | None = None
def set_mode(self, mode: str) -> None:
self.mode = mode
self._clear_preview()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.mode == "pointer":
super().mousePressEvent(event)
return
point = self._bounded(event.scenePos())
if self.mode == "text":
text, accepted = QInputDialog.getText(self.editor, "Add Text", "Text:", text="Text")
if accepted:
self.editor.create_shape("text", point, point + QPointF(55, 35), text)
event.accept()
return
self.start = point
self.preview = QGraphicsPathItem()
self.preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
self.preview.setZValue(100)
self.addItem(self.preview)
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None or self.preview is None:
super().mouseMoveEvent(event)
return
end = self._bounded(event.scenePos())
path = QPainterPath()
rect = QRectF(self.start, end).normalized()
if self.mode == "line":
path.moveTo(self.start)
path.lineTo(end)
elif self.mode in {"circle", "ellipse"}:
if self.mode == "circle":
side = min(rect.width(), rect.height())
rect.setSize(QSizeF(side, side))
path.addEllipse(rect)
else:
path.addRect(rect)
self.preview.setPath(path)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None:
super().mouseReleaseEvent(event)
return
end = self._bounded(event.scenePos())
start = self.start
self._clear_preview()
if start != end:
self.editor.create_shape(self.mode, start, end)
event.accept()
def _bounded(self, point: QPointF) -> QPointF:
rect = self.sceneRect()
return QPointF(
min(rect.right(), max(rect.left(), _snap(point.x()))),
min(rect.bottom(), max(rect.top(), _snap(point.y()))),
)
def _clear_preview(self) -> None:
if self.preview is not None:
self.removeItem(self.preview)
self.preview = None
self.start = None
class IconEditorDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
@@ -275,6 +400,10 @@ class IconEditorDialog(QDialog):
self.icon = Icon.from_dict(component.icon.to_dict())
self.inputs = deepcopy(component.inputs)
self.outputs = deepcopy(component.outputs)
self.tool_group = QButtonGroup(self)
self.tool_group.setExclusive(True)
self.tool_group.addButton(self.ui.pointerButton)
self.ui.pointerButton.clicked.connect(lambda: self.set_draw_mode("pointer"))
for button, kind in (
(self.ui.addRectangleButton, "rectangle"),
(self.ui.addCircleButton, "circle"),
@@ -283,43 +412,70 @@ class IconEditorDialog(QDialog):
(self.ui.addTriangleButton, "triangle"),
(self.ui.addTextButton, "text"),
):
button.clicked.connect(
lambda _checked=False, value=kind: self.add_shape(value)
)
self.tool_group.addButton(button)
button.clicked.connect(lambda _checked=False, value=kind: self.set_draw_mode(value))
self.ui.deleteSelectedButton.clicked.connect(self.delete_selected)
self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self)
self.scene = IconDrawingScene(self, QRectF(0, 0, self.icon.width, self.icon.height))
self.ui.iconView.setScene(self.scene)
self.view = self.ui.iconView
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.ui.zoomInButton.clicked.connect(self.view.zoom_in)
self.ui.zoomOutButton.clicked.connect(self.view.zoom_out)
self.ui.centerButton.clicked.connect(self.view.center_icon)
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
for element in self.icon.elements:
self.scene.addItem(ShapeItem(element))
self._add_ports(self.inputs, "input", 0.0)
self._add_ports(self.outputs, "output", self.icon.width)
self.view.fitInView(self.scene.sceneRect().adjusted(-10, -10, 10, 10), Qt.AspectRatioMode.KeepAspectRatio)
self.view.center_icon()
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
spacing = self.icon.height / (len(ports) + 1)
for index, port in enumerate(ports, 1):
saved = port.properties.get("iconPosition", {})
position = QPointF(float(saved.get("x", default_x)), float(saved.get("y", spacing * index)))
position = QPointF(
float(saved.get("x", default_x)), float(saved.get("y", spacing * index))
)
self.scene.addItem(PortHandle(port, direction, position))
def add_shape(self, kind: str) -> None:
count = len([item for item in self.scene.items() if isinstance(item, ShapeItem)])
x, y = 15 + (count * 5) % 40, 15 + (count * 4) % 25
element = {"type": kind, "x": x, "y": y, "width": 55, "height": 35, "fill": "#dbeafe", "stroke": "#303030", "lineWidth": 1.5, "lineStyle": "solid"}
def set_draw_mode(self, mode: str) -> None:
self.scene.set_mode(mode)
self.view.setDragMode(
self.view.DragMode.RubberBandDrag if mode == "pointer" else self.view.DragMode.NoDrag
)
def create_shape(self, kind: str, start: QPointF, end: QPointF, text: str = "Text") -> None:
left, right = sorted((start.x(), end.x()))
top, bottom = sorted((start.y(), end.y()))
element = {
"type": kind,
"x": left,
"y": top,
"width": max(_icon_grid_size(), right - left),
"height": max(_icon_grid_size(), bottom - top),
"fill": "#dbeafe",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
}
if kind == "circle":
element["width"] = element["height"] = 35
element["width"] = element["height"] = min(element["width"], element["height"])
if kind == "line":
element["x"], element["y"] = start.x(), start.y()
element["width"], element["height"] = end.x() - start.x(), end.y() - start.y()
element["fill"] = "none"
if kind == "rectangle":
element["cornerRadius"] = 0
if kind == "text":
text, accepted = QInputDialog.getText(self, "Add Text", "Text:", text="Text")
if not accepted:
return
element.update({"text": text, "fontSize": 12, "color": "#202020", "fill": "none", "lineStyle": "none"})
element.update(
{
"text": text,
"fontSize": 12,
"color": "#202020",
"fill": "none",
"lineStyle": "none",
}
)
self.icon.elements.append(element)
item = ShapeItem(element)
self.scene.addItem(item)
@@ -332,5 +488,7 @@ class IconEditorDialog(QDialog):
item.element["_deleted"] = True
def accept(self) -> None:
self.icon.elements = [element for element in self.icon.elements if not element.pop("_deleted", False)]
self.icon.elements = [
element for element in self.icon.elements if not element.pop("_deleted", False)
]
super().accept()

View File

@@ -13,14 +13,14 @@ def icon_bounds(icon: Icon) -> QRectF:
rect = QRectF(
float(element.get("x", 0)),
float(element.get("y", 0)),
max(0.0, float(element.get("width", 0))),
max(0.0, float(element.get("height", 0))),
)
float(element.get("width", 0)),
float(element.get("height", 0)),
).normalized()
bounds = rect if bounds.isNull() else bounds.united(rect)
return bounds if not bounds.isNull() else QRectF(32, 32, 64, 64)
def _pen(element: dict) -> QPen:
def shape_pen(element: dict) -> QPen:
styles = {
"solid": Qt.PenStyle.SolidLine,
"dash": Qt.PenStyle.DashLine,
@@ -51,12 +51,16 @@ def paint_icon(
for element in icon.elements:
kind = element.get("type", "rectangle")
rect = QRectF(
float(element.get("x", 0)), float(element.get("y", 0)),
float(element.get("width", 20)), float(element.get("height", 20)),
float(element.get("x", 0)),
float(element.get("y", 0)),
float(element.get("width", 20)),
float(element.get("height", 20)),
)
painter.setPen(_pen(element))
painter.setPen(shape_pen(element))
fill = element.get("fill", "#ffffff")
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
if kind == "rectangle":
radius = float(element.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
@@ -65,7 +69,11 @@ def paint_icon(
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]))
painter.drawPolygon(
QPolygonF(
[QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]
)
)
elif kind == "text":
painter.setPen(QColor(element.get("color", element.get("stroke", "#202020"))))
font = QFont()

View File

@@ -24,15 +24,18 @@ from PySide6.QtWidgets import (
QGraphicsView,
QApplication,
QMenu,
QInputDialog,
QStyleOptionGraphicsItem,
QToolTip,
QWidget,
)
from bedit.core.model import Component, Connection, Endpoint, Port
from bedit.core.model import Annotation, Component, Connection, Endpoint, Port
from bedit.gui.controllers.document import DocumentController
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
from bedit.gui.graphics.icon_renderer import icon_bounds, paint_icon
from bedit.gui.graphics.icon_editor import ShapeOptionsDialog
from bedit.gui.graphics.icon_renderer import shape_pen
from bedit.gui.graphics.connection_styles import ConnectionStyle, connection_style
from bedit.gui.preferences import application_settings
@@ -94,7 +97,9 @@ class ComponentGraphicsItem(QGraphicsObject):
position = port.properties.get("iconPosition", {})
item.setPos(
float(position.get("x", x)) * self.WIDTH / self.component.icon.width,
float(position.get("y", spacing * index)) * self.HEIGHT / self.component.icon.height,
float(position.get("y", spacing * index))
* self.HEIGHT
/ self.component.icon.height,
)
result[port.id] = item
return result
@@ -212,6 +217,9 @@ class InterfaceTerminalItem(QGraphicsObject):
super().mousePressEvent(event)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if self.index < 0:
event.accept()
return
menu = QMenu()
options_action = menu.addAction(f"{self.direction.title()} Options…")
if menu.exec(event.screenPos()) is options_action:
@@ -253,13 +261,24 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.end_direction = QPointF(1, 0)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self._update_pen()
self.setZValue(-1)
self.setZValue(0)
self.setToolTip(name or "Connection")
self.handles: list[WaypointHandle] = []
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
self.scene().clearSelection()
self.setSelected(True)
menu = QMenu()
add_node_action = menu.addAction("Add Node")
menu.addSeparator()
options_action = menu.addAction("Connection Options…")
if menu.exec(event.screenPos()) is options_action:
selected = menu.exec(event.screenPos())
if selected is add_node_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.add_route_node("connection", self.connection_id, event.scenePos())
elif selected is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.connectionOptionsRequested.emit(self.connection_id)
@@ -269,6 +288,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self._update_pen()
self._update_handles()
return result
def _update_pen(self) -> None:
@@ -291,6 +311,24 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.start_direction = points[1] - points[0]
self.end_direction = points[-1] - points[-2]
def set_waypoints(self, points: list[QPointF]) -> None:
for handle in self.handles:
if handle.scene() is not None:
handle.scene().removeItem(handle)
self.handles = [
WaypointHandle("connection", self.connection_id, index, point)
for index, point in enumerate(points)
]
scene = self.scene()
if scene is not None:
for handle in self.handles:
scene.addItem(handle)
self._update_handles()
def _update_handles(self) -> None:
for handle in self.handles:
handle.setVisible(self.isSelected())
@staticmethod
def _arrow(end: QPointF, direction: QPointF, size: float) -> QPolygonF:
length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5)
@@ -306,7 +344,211 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
if self.style.arrow_at_target:
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
if self.style.arrow_at_source:
painter.drawPolygon(self._arrow(self.start, -self.start_direction, self.style.arrow_size))
painter.drawPolygon(
self._arrow(self.start, -self.start_direction, self.style.arrow_size)
)
class WaypointHandle(QGraphicsEllipseItem):
def __init__(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
super().__init__(-5, -5, 10, 10)
self.item_kind, self.item_id, self.index = item_kind, item_id, index
self.setPos(position)
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setZValue(1000)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setCursor(Qt.CursorShape.SizeAllCursor)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
snapped = _snapped(value)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.preview_route_node(self.item_kind, self.item_id, self.index, snapped)
return snapped
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.move_route_node(self.item_kind, self.item_id, self.index, self.pos())
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
delete_action = menu.addAction("Delete Node")
if menu.exec(event.screenPos()) is delete_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.delete_route_node(self.item_kind, self.item_id, self.index)
event.accept()
class AnnotationGraphicsItem(QGraphicsObject):
def __init__(self, annotation: Annotation, controller: DocumentController) -> None:
super().__init__()
self.annotation = annotation
self.annotation_id = annotation.id
self.controller = controller
self.drag_start = QPointF(annotation.x, annotation.y)
self.setPos(annotation.x, annotation.y)
self.setZValue(annotation.layer)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def boundingRect(self) -> QRectF: # noqa: N802
width, height = self.annotation.width, self.annotation.height
return QRectF(min(0, width), min(0, height), abs(width), abs(height)).adjusted(-4, -4, 4, 4)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(0, 0, self.annotation.width, self.annotation.height).normalized()
painter.setPen(shape_pen(self.annotation.properties))
fill = self.annotation.properties.get("fill", "none")
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
if self.annotation.kind == "box":
radius = float(self.annotation.properties.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
else:
painter.setPen(
QColor(
self.annotation.properties.get(
"color", self.annotation.properties.get("stroke", "#202020")
)
)
)
font = painter.font()
font.setPointSizeF(float(self.annotation.properties.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(
rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, self.annotation.text
)
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
painter.drawRect(self.boundingRect())
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
new_pos = _snapped(self.pos())
self.setPos(new_pos)
old = {
"x": self.drag_start.x(),
"y": self.drag_start.y(),
"width": self.annotation.width,
"height": self.annotation.height,
}
new = {**old, "x": new_pos.x(), "y": new_pos.y()}
self.controller.set_annotation_geometry(self.annotation_id, old, new)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
return _snapped(value)
return super().itemChange(change, value)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
scene = self.scene()
if not isinstance(scene, GraphScene):
return
if not self.isSelected():
scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options_action = menu.addAction("Shape Options…")
menu.addSeparator()
actions = {
menu.addAction("Bring Forward"): "forward",
menu.addAction("Send Backward"): "backward",
menu.addAction("Bring to Front"): "front",
menu.addAction("Send to Back"): "back",
}
selected = menu.exec(event.screenPos())
if selected is options_action:
scene.edit_annotation_options(self.annotation_id)
elif selected in actions:
scene.reorder_selected_annotations(actions[selected])
event.accept()
class LineAnnotationGraphicsItem(QGraphicsPathItem):
def __init__(self, annotation: Annotation) -> None:
super().__init__()
self.annotation = annotation
self.annotation_id = annotation.id
self.handles: list[WaypointHandle] = []
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setZValue(annotation.layer)
self._update_pen()
def _update_pen(self) -> None:
pen = shape_pen(self.annotation.properties)
if self.isSelected():
pen.setColor(QColor("#2563eb"))
pen.setWidthF(max(3.0, pen.widthF()))
self.setPen(pen)
def set_waypoints(self, points: list[QPointF], start: QPointF, end: QPointF) -> None:
for handle in self.handles:
if handle.scene() is not None:
handle.scene().removeItem(handle)
self.handles = [WaypointHandle("annotation", self.annotation_id, -1, start)]
self.handles.extend(
WaypointHandle("annotation", self.annotation_id, index, point)
for index, point in enumerate(points)
)
self.handles.append(WaypointHandle("annotation", self.annotation_id, -2, end))
scene = self.scene()
if scene is not None:
for handle in self.handles:
scene.addItem(handle)
for handle in self.handles:
handle.setVisible(self.isSelected())
def itemChange(self, change, value): # noqa: N802
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
for handle in self.handles:
handle.setVisible(self.isSelected())
self._update_pen()
return result
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
self.scene().clearSelection()
self.setSelected(True)
menu = QMenu()
add_action = menu.addAction("Add Node")
options_action = menu.addAction("Shape Options…")
menu.addSeparator()
actions = {
menu.addAction("Bring Forward"): "forward",
menu.addAction("Send Backward"): "backward",
menu.addAction("Bring to Front"): "front",
menu.addAction("Send to Back"): "back",
}
selected = menu.exec(event.screenPos())
scene = self.scene()
if isinstance(scene, GraphScene):
if selected is add_action:
scene.add_route_node("annotation", self.annotation_id, event.scenePos())
elif selected is options_action:
scene.edit_annotation_options(self.annotation_id)
elif selected in actions:
scene.reorder_selected_annotations(actions[selected])
event.accept()
class GraphScene(QGraphicsScene):
@@ -322,17 +564,21 @@ class GraphScene(QGraphicsScene):
self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.annotation_items: dict[str, QGraphicsItem] = {}
self.pending_source: ConnectionPortItem | None = None
self.pending_waypoints: list[QPointF] = []
self.pending_preview: QGraphicsPathItem | None = None
self.interaction_mode = "pointer"
self.connection_routing = "spline"
self.connection_routing = "angled"
self.drawing_start: QPointF | None = None
self.drawing_waypoints: list[QPointF] = []
self.setSceneRect(-2000, -2000, 4000, 4000)
controller.documentReset.connect(self.rebuild)
controller.activeGraphChanged.connect(self.rebuild)
controller.componentMoved.connect(self.set_component_position)
controller.componentRotated.connect(self.set_component_rotation)
controller.graphItemChanged.connect(self.refresh_graph_item)
self.rebuild()
def rebuild(self) -> None:
@@ -341,6 +587,7 @@ class GraphScene(QGraphicsScene):
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.annotation_items.clear()
self.pending_source = None
self.pending_waypoints.clear()
self.pending_preview = None
@@ -371,6 +618,16 @@ class GraphScene(QGraphicsScene):
self.addItem(item)
self.connection_items[connection.id] = item
self.update_connection(connection.id)
for annotation in owner.graph.annotations.values():
if annotation.kind == "line":
item = LineAnnotationGraphicsItem(annotation)
self.addItem(item)
self.annotation_items[annotation.id] = item
self.update_annotation(annotation.id)
else:
item = AnnotationGraphicsItem(annotation, self.controller)
self.addItem(item)
self.annotation_items[annotation.id] = item
def set_component_position(self, component_id: str, position: QPointF) -> None:
item = self.component_items.get(component_id)
@@ -383,6 +640,52 @@ class GraphScene(QGraphicsScene):
item.setRotation(rotation)
self.update_connections_for_block(component_id)
def refresh_graph_item(self, item_kind: str, item_id: str) -> None:
if item_kind == "connection":
self.update_connection(item_id)
return
annotation = self.controller.active_graph.annotations.get(item_id)
graphics = self.annotation_items.get(item_id)
if annotation is None or graphics is None:
return
graphics.setZValue(annotation.layer)
if isinstance(graphics, LineAnnotationGraphicsItem):
graphics._update_pen()
self.update_annotation(item_id)
elif isinstance(graphics, AnnotationGraphicsItem):
graphics.prepareGeometryChange()
graphics.setPos(annotation.x, annotation.y)
graphics.update()
def edit_annotation_options(self, annotation_id: str) -> None:
annotation = self.controller.active_graph.annotations.get(annotation_id)
if annotation is None:
return
element = {
"type": {"box": "rectangle", "line": "line", "text": "text"}[annotation.kind],
"width": abs(annotation.width),
"height": abs(annotation.height),
"text": annotation.text,
**annotation.properties,
}
dialog = ShapeOptionsDialog(element)
if dialog.exec() != dialog.DialogCode.Accepted:
return
values = annotation.to_dict()
values["size"] = {
"width": dialog.element["width"] * (-1 if annotation.width < 0 else 1),
"height": dialog.element["height"] * (-1 if annotation.height < 0 else 1),
}
values["text"] = str(dialog.element.get("text", annotation.text))
values["properties"].update(
{
key: value
for key, value in dialog.element.items()
if key not in {"type", "x", "y", "width", "height", "text"}
}
)
self.controller.edit_annotation(annotation_id, values)
def update_connections_for_block(self, component_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if component_id in (connection.source.block, connection.target.block):
@@ -418,7 +721,7 @@ class GraphScene(QGraphicsScene):
if source is None or target is None:
return
start, end = source.scenePos(), target.scenePos()
routing = str(connection.properties.get("routing", "spline"))
routing = str(connection.properties.get("routing", "angled"))
waypoints = [
QPointF(float(point["x"]), float(point["y"]))
for point in connection.properties.get("waypoints", [])
@@ -426,6 +729,35 @@ class GraphScene(QGraphicsScene):
]
path, direction_points = self._route_path(start, end, routing, waypoints)
graphics.set_connection_path(path, direction_points)
graphics.set_waypoints(waypoints)
def update_annotation(self, annotation_id: str) -> None:
annotation = self.controller.active_graph.annotations.get(annotation_id)
graphics = self.annotation_items.get(annotation_id)
if annotation is None or not isinstance(graphics, LineAnnotationGraphicsItem):
return
start = QPointF(annotation.x, annotation.y)
end = start + QPointF(annotation.width, annotation.height)
waypoints = [
QPointF(float(p["x"]), float(p["y"]))
for p in annotation.properties.get("waypoints", [])
]
routing = str(annotation.properties.get("routing", "angled"))
path, points = self._route_path(start, end, routing, waypoints)
graphics.setPath(path)
graphics.set_waypoints(waypoints, start, end)
@staticmethod
def _orthogonal_points(start: QPointF, end: QPointF, waypoints: list[QPointF]) -> list[QPointF]:
points = [start]
for waypoint in [*waypoints, end]:
previous = points[-1]
if previous.x() != waypoint.x() and previous.y() != waypoint.y():
elbow = QPointF(waypoint.x(), previous.y())
points.append(elbow)
if waypoint != points[-1]:
points.append(waypoint)
return points
@staticmethod
def _route_path(
@@ -437,16 +769,23 @@ class GraphScene(QGraphicsScene):
waypoints = waypoints or []
path = QPainterPath(start)
if routing == "angled":
points = [start, *waypoints, end]
points = GraphScene._orthogonal_points(start, end, waypoints)
for point in points[1:]:
path.lineTo(point)
return path, points
if routing == "direct":
path.lineTo(end)
return path, [start, end]
if waypoints:
points = [start, *waypoints, end]
for index, point in enumerate(points[1:-1], start=1):
following = points[index + 1]
midpoint = QPointF((point.x() + following.x()) / 2, (point.y() + following.y()) / 2)
path.quadTo(point, midpoint)
path.quadTo(points[-2], end)
return path, points
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
first_control = start + QPointF(distance, 0)
second_control = end - QPointF(distance, 0)
first_control, second_control = start + QPointF(distance, 0), end - QPointF(distance, 0)
path.cubicTo(first_control, second_control, end)
return path, [start, first_control, second_control, end]
@@ -454,12 +793,16 @@ class GraphScene(QGraphicsScene):
self.interaction_mode = mode
if mode != "connect":
self._clear_pending_source()
self._clear_drawing()
def set_connection_routing(self, routing: str) -> None:
self.connection_routing = routing
self._clear_pending_source()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode in {"box", "text", "line"}:
self._drawing_press(event)
return
if self.interaction_mode != "connect":
self._clear_pending_source()
super().mousePressEvent(event)
@@ -483,7 +826,15 @@ class GraphScene(QGraphicsScene):
self.pending_source.endpoint,
item.endpoint,
routing=self.connection_routing,
waypoints=self.pending_waypoints,
waypoints=(
self._completed_angled_waypoints(
self.pending_source.scenePos(),
item.scenePos(),
self.pending_waypoints,
)
if self.connection_routing == "angled"
else self.pending_waypoints
),
)
except ValueError as error:
QToolTip.showText(event.screenPos(), str(error))
@@ -500,8 +851,222 @@ class GraphScene(QGraphicsScene):
self._update_preview(event.scenePos())
event.accept()
return
if self.interaction_mode in {"box", "line"} and self.drawing_start is not None:
self._update_drawing_preview(event.scenePos())
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode == "box" and self.drawing_start is not None:
end = _snapped(event.scenePos())
if end != self.drawing_start:
self.controller.add_annotation("box", self.drawing_start, end)
self._clear_drawing()
event.accept()
return
super().mouseReleaseEvent(event)
def _drawing_press(self, event: QGraphicsSceneMouseEvent) -> None:
if event.button() == Qt.MouseButton.RightButton:
self._clear_drawing()
event.accept()
return
point = _snapped(event.scenePos())
if self.interaction_mode == "text":
text_value, accepted = QInputDialog.getText(None, "Add Text", "Text:")
if accepted and text_value:
self.controller.add_annotation(
"text", point, point + QPointF(160, 48), text=text_value
)
event.accept()
return
if self.drawing_start is None:
self.drawing_start = point
self.drawing_waypoints = []
self._create_preview()
elif self.interaction_mode == "line" and self.connection_routing == "angled":
self.drawing_waypoints.append(point)
self._update_drawing_preview(point)
elif self.interaction_mode == "line":
self.controller.add_annotation(
"line", self.drawing_start, point, routing=self.connection_routing
)
self._clear_drawing()
event.accept()
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode == "line" and self.drawing_start is not None:
end = _snapped(event.scenePos())
waypoints = (
self.drawing_waypoints[:-1]
if self.drawing_waypoints and self.drawing_waypoints[-1] == end
else self.drawing_waypoints
)
completed = self._completed_angled_waypoints(self.drawing_start, end, waypoints)
self.controller.add_annotation(
"line", self.drawing_start, end, routing="angled", waypoints=completed
)
self._clear_drawing()
event.accept()
return
super().mouseDoubleClickEvent(event)
@staticmethod
def _completed_angled_waypoints(
start: QPointF, end: QPointF, waypoints: list[QPointF]
) -> list[QPointF]:
return GraphScene._orthogonal_points(start, end, waypoints)[1:-1]
def _update_drawing_preview(self, cursor: QPointF) -> None:
if self.drawing_start is None or self.pending_preview is None:
return
end = _snapped(cursor)
if self.interaction_mode == "box":
path = QPainterPath()
path.addRect(QRectF(self.drawing_start, end).normalized())
else:
path, _ = self._route_path(
self.drawing_start, end, self.connection_routing, self.drawing_waypoints
)
self.pending_preview.setPath(path)
def _clear_drawing(self) -> None:
if self.pending_preview is not None and self.pending_source is None:
self.removeItem(self.pending_preview)
self.pending_preview = None
self.drawing_start = None
self.drawing_waypoints.clear()
def _route_values(self, item_kind: str, item_id: str):
item = (
self.controller.active_graph.connections
if item_kind == "connection"
else self.controller.active_graph.annotations
).get(item_id)
if item is None:
return None, []
points = [
QPointF(float(p["x"]), float(p["y"])) for p in item.properties.get("waypoints", [])
]
return item, points
def add_route_node(self, item_kind: str, item_id: str, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:
return
snapped = _snapped(position)
if item_kind == "annotation":
start = QPointF(item.x, item.y)
end = QPointF(item.x + item.width, item.y + item.height)
else:
graphics = self.connection_items[item_id]
start, end = graphics.start, graphics.end
anchors = [start, *points, end]
def segment_distance(point: QPointF, first: QPointF, second: QPointF) -> float:
delta = second - first
length_squared = delta.x() ** 2 + delta.y() ** 2
if length_squared == 0:
return (point - first).manhattanLength()
ratio = max(
0.0,
min(
1.0,
((point.x() - first.x()) * delta.x() + (point.y() - first.y()) * delta.y())
/ length_squared,
),
)
nearest = first + delta * ratio
return (point.x() - nearest.x()) ** 2 + (point.y() - nearest.y()) ** 2
insertion = min(
range(len(anchors) - 1),
key=lambda index: segment_distance(snapped, anchors[index], anchors[index + 1]),
)
points.insert(insertion, snapped)
routing = "angled" if item.properties.get("routing") == "direct" else None
self.controller.set_route_waypoints(item_kind, item_id, points, routing)
def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:
return
position = _snapped(position)
if item_kind == "annotation" and index in {-1, -2}:
old = {
"x": item.x,
"y": item.y,
"width": item.width,
"height": item.height,
}
start = position if index == -1 else QPointF(item.x, item.y)
end = position if index == -2 else QPointF(item.x + item.width, item.y + item.height)
new = {
"x": start.x(),
"y": start.y(),
"width": end.x() - start.x(),
"height": end.y() - start.y(),
}
self.controller.set_annotation_geometry(item_id, old, new)
return
if index < len(points):
points[index] = position
self.controller.set_route_waypoints(item_kind, item_id, points)
def preview_route_node(
self, item_kind: str, item_id: str, index: int, position: QPointF
) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:
return
if item_kind == "annotation" and index in {-1, -2}:
graphics = self.annotation_items.get(item_id)
if not isinstance(graphics, LineAnnotationGraphicsItem):
return
start = position if index == -1 else QPointF(item.x, item.y)
end = position if index == -2 else QPointF(item.x + item.width, item.y + item.height)
path, _direction_points = self._route_path(
start,
end,
str(item.properties.get("routing", "angled")),
points,
)
graphics.setPath(path)
return
if index >= len(points):
return
points[index] = position
routing = str(item.properties.get("routing", "angled"))
if item_kind == "connection":
graphics = self.connection_items.get(item_id)
if graphics is None:
return
path, direction_points = self._route_path(graphics.start, graphics.end, routing, points)
graphics.set_connection_path(path, direction_points)
return
graphics = self.annotation_items.get(item_id)
if not isinstance(graphics, LineAnnotationGraphicsItem):
return
start = QPointF(item.x, item.y)
end = QPointF(item.x + item.width, item.y + item.height)
path, _direction_points = self._route_path(start, end, routing, points)
graphics.setPath(path)
def delete_route_node(self, item_kind: str, item_id: str, index: int) -> None:
_item, points = self._route_values(item_kind, item_id)
if 0 <= index < len(points):
points.pop(index)
self.controller.set_route_waypoints(item_kind, item_id, points)
def reorder_selected_annotations(self, operation: str) -> None:
ids = {
item.annotation_id
for item in self.selectedItems()
if isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem))
}
self.controller.reorder_annotations(ids, operation)
def _create_preview(self) -> None:
self.pending_preview = QGraphicsPathItem()
self.pending_preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
@@ -511,9 +1076,10 @@ class GraphScene(QGraphicsScene):
def _update_preview(self, cursor: QPointF) -> None:
if self.pending_source is None or self.pending_preview is None:
return
preview_cursor = _snapped(cursor) if self.connection_routing == "angled" else cursor
path, _points = self._route_path(
self.pending_source.scenePos(),
cursor,
preview_cursor,
self.connection_routing,
self.pending_waypoints,
)
@@ -622,6 +1188,7 @@ class GraphWorkspaceView(QGraphicsView):
connections: set[str] = set()
inputs: set[str] = set()
outputs: set[str] = set()
annotations: set[str] = set()
for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem):
blocks.add(item.component_id)
@@ -629,32 +1196,29 @@ class GraphWorkspaceView(QGraphicsView):
connections.add(item.connection_id)
elif isinstance(item, InterfaceTerminalItem):
(inputs if item.direction == "input" else outputs).add(item.port.id)
elif isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem)):
annotations.add(item.annotation_id)
self.controller.delete_selection(blocks, connections, inputs, outputs)
self.controller.delete_annotations(annotations)
def has_selected_components(self) -> bool:
scene = self.scene()
return bool(
scene
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
scene and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
)
def has_single_selected_component(self) -> bool:
scene = self.scene()
return bool(
scene
and sum(
isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()
)
== 1
and sum(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()) == 1
)
def open_selected_component(self) -> bool:
if self.controller is None or self.scene() is None:
return False
selected = [
item
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
item for item in self.scene().selectedItems() if isinstance(item, ComponentGraphicsItem)
]
if len(selected) != 1:
return False

View File

@@ -4,7 +4,14 @@ from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QButtonGroup, QFileDialog, QMainWindow, QMenu, QMessageBox
from PySide6.QtWidgets import (
QButtonGroup,
QFileDialog,
QMainWindow,
QMenu,
QMessageBox,
QTabWidget,
)
from bedit.core.model import Component, Port
from bedit.core.serializer import JsonDocumentSerializer
@@ -51,11 +58,11 @@ class MainWindow(QMainWindow):
self.ui.leftDockHost.show()
self.ui.panel_libraries.show()
self.ui.panel_document.show()
self.ui.leftDockHost.splitDockWidget(
self.ui.panel_document,
self.ui.panel_libraries,
Qt.Orientation.Vertical,
self.ui.leftDockHost.setTabPosition(
Qt.DockWidgetArea.LeftDockWidgetArea, QTabWidget.TabPosition.North
)
self.ui.leftDockHost.tabifyDockWidget(self.ui.panel_document, self.ui.panel_libraries)
self.ui.panel_document.raise_()
self.ui.workspaceSplitter.setSizes([280, 720])
self.reload_libraries()
self._active_graph_changed()
@@ -67,22 +74,14 @@ class MainWindow(QMainWindow):
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.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.setHeaderHidden(True)
self.ui.documentTreeView.setDragEnabled(True)
self.ui.documentTreeView.setDragDropMode(
self.ui.documentTreeView.DragDropMode.DragOnly
)
self.ui.documentTreeView.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.ui.documentTreeView.customContextMenuRequested.connect(
self.show_library_context_menu
)
self.ui.documentTreeView.setDragDropMode(self.ui.documentTreeView.DragDropMode.DragOnly)
self.ui.documentTreeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.ui.documentTreeView.customContextMenuRequested.connect(self.show_library_context_menu)
self.ui.documentTreeView.doubleClicked.connect(self.activate_tree_component)
self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
self.ui.graphView.set_model(self.document_controller)
@@ -97,6 +96,9 @@ class MainWindow(QMainWindow):
self.mode_button_group.setExclusive(True)
self.mode_button_group.addButton(self.ui.pointerToolButton)
self.mode_button_group.addButton(self.ui.connectToolButton)
self.mode_button_group.addButton(self.ui.boxToolButton)
self.mode_button_group.addButton(self.ui.lineToolButton)
self.mode_button_group.addButton(self.ui.textToolButton)
self.routing_button_group = QButtonGroup(self)
self.routing_button_group.setExclusive(True)
for button in (
@@ -109,15 +111,13 @@ class MainWindow(QMainWindow):
self.ui.navigateDownButton.clicked.connect(self.navigate_down)
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
self.ui.connectToolButton.clicked.connect(lambda: self.set_graph_tool("connect"))
self.ui.directRoutingButton.clicked.connect(
lambda: self.set_connection_routing("direct")
)
self.ui.angledRoutingButton.clicked.connect(
lambda: self.set_connection_routing("angled")
)
self.ui.splineRoutingButton.clicked.connect(
lambda: self.set_connection_routing("spline")
)
self.ui.boxToolButton.clicked.connect(lambda: self.set_graph_tool("box"))
self.ui.lineToolButton.clicked.connect(lambda: self.set_graph_tool("line"))
self.ui.textToolButton.clicked.connect(lambda: self.set_graph_tool("text"))
self.ui.rotateToolButton.clicked.connect(self.ui.graphView.rotate_selected)
self.ui.directRoutingButton.clicked.connect(lambda: self.set_connection_routing("direct"))
self.ui.angledRoutingButton.clicked.connect(lambda: self.set_connection_routing("angled"))
self.ui.splineRoutingButton.clicked.connect(lambda: self.set_connection_routing("spline"))
self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
@@ -160,7 +160,6 @@ class MainWindow(QMainWindow):
for toolbar in (
self.ui.fileToolbar,
self.ui.editToolbar,
self.ui.transformToolbar,
self.ui.cameraToolbar,
):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
@@ -184,7 +183,11 @@ class MainWindow(QMainWindow):
if self.document_controller.document is None:
self.setWindowTitle("BEdit")
return
name = self.document_controller.file_path.name if self.document_controller.file_path else "Untitled"
name = (
self.document_controller.file_path.name
if self.document_controller.file_path
else "Untitled"
)
modified = "*" if not self.document_controller.undo_stack.isClean() else ""
self.setWindowTitle(f"{modified}{name} — BEdit")
@@ -217,12 +220,17 @@ class MainWindow(QMainWindow):
def _update_edit_actions(self) -> None:
component = self.document_controller.active_component
is_graph = component is not None and component.implementation_kind == "graph"
has_selection = bool(self.ui.graphView.scene() and self.ui.graphView.scene().selectedItems())
has_selection = bool(
self.ui.graphView.scene() and self.ui.graphView.scene().selectedItems()
)
for action in (self.ui.actionCut, self.ui.actionCopy, self.ui.actionDelete):
action.setEnabled(is_graph and has_selection)
self.ui.actionRotateClockwise.setEnabled(
is_graph and self.ui.graphView.has_selected_components()
)
self.ui.rotateToolButton.setEnabled(
is_graph and self.ui.graphView.has_selected_components()
)
self.ui.actionSelectAll.setEnabled(is_graph)
self.ui.actionPaste.setEnabled(is_graph)
self.ui.navigateDownButton.setEnabled(
@@ -243,6 +251,10 @@ class MainWindow(QMainWindow):
for widget in (
self.ui.pointerToolButton,
self.ui.connectToolButton,
self.ui.boxToolButton,
self.ui.lineToolButton,
self.ui.textToolButton,
self.ui.rotateToolButton,
self.ui.routingLabel,
self.ui.directRoutingButton,
self.ui.angledRoutingButton,
@@ -252,7 +264,13 @@ class MainWindow(QMainWindow):
def set_graph_tool(self, mode: str) -> None:
self.ui.graphView.set_tool_mode(mode)
(self.ui.pointerToolButton if mode == "pointer" else self.ui.connectToolButton).setChecked(True)
{
"pointer": self.ui.pointerToolButton,
"connect": self.ui.connectToolButton,
"box": self.ui.boxToolButton,
"line": self.ui.lineToolButton,
"text": self.ui.textToolButton,
}[mode].setChecked(True)
def set_connection_routing(self, routing: str) -> None:
self.ui.graphView.set_connection_routing(routing)
@@ -312,9 +330,7 @@ class MainWindow(QMainWindow):
raise ValueError("'source' must be an object")
inputs = [Port.from_dict(item) for item in data["inputs"]]
outputs = [Port.from_dict(item) for item in data["outputs"]]
self.document_controller.replace_active_text_definition(
inputs, outputs, data["source"]
)
self.document_controller.replace_active_text_definition(inputs, outputs, data["source"])
except (TypeError, ValueError, json.JSONDecodeError) as error:
QMessageBox.critical(self, "Invalid text component JSON", str(error))
return False
@@ -497,9 +513,7 @@ class MainWindow(QMainWindow):
try:
if library is not None:
library.document.validate()
JsonDocumentSerializer.save(
library.document, Path(library.source_path)
)
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))
@@ -559,9 +573,7 @@ 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)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.rename_connection(connection_id, dialog.name)