Basic document handling

This commit is contained in:
2026-07-19 21:26:27 +02:00
parent c5ab3329ae
commit 76bd313f79
32 changed files with 4843 additions and 67 deletions

View File

@@ -1,7 +1,7 @@
import sys
from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QColor, QPalette, QIcon
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication, QStyleFactory
from bedit.main_window import MainWindow

View File

@@ -0,0 +1,31 @@
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QDialog, QMessageBox
from bedit.document.model import Component
from bedit.ui_component_options_dialog import Ui_ComponentOptionsDialog
class ComponentOptionsDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_ComponentOptionsDialog()
self.ui.setupUi(self)
self.ui.nameEdit.setText(component.name)
self.ui.shapeCombo.setCurrentText(component.icon.shape)
self.ui.iconTextEdit.setText(component.icon.text)
self.ui.fillEdit.setText(component.icon.fill)
self.ui.borderEdit.setText(component.icon.border)
self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library)
def accept(self) -> None:
if not self.ui.nameEdit.text().strip():
QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.")
return
for label, value in (
("fill", self.ui.fillEdit.text()),
("border", self.ui.borderEdit.text()),
):
if not QColor(value).isValid():
QMessageBox.warning(self, "Invalid color", f"The {label} color is not valid.")
return
super().accept()

View File

@@ -0,0 +1,52 @@
{
"format": "bedit-document",
"version": 1,
"metadata": {"name": "Example Library"},
"roots": [
{
"id": "example-a",
"name": "Block A",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}],
"outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}]
},
"icon": {"shape": "rectangle", "fill": "#dbeafe", "border": "#245c9c", "text": "A"},
"properties": {},
"library": {"showSubtree": true},
"implementation": {
"kind": "text",
"source": {"equations": ["out = gain * in"], "parameters": {"gain": 1.0}}
}
},
{
"id": "example-b",
"name": "Block B",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}],
"outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}]
},
"icon": {"shape": "ellipse", "fill": "#dcfce7", "border": "#277342", "text": "B"},
"properties": {},
"library": {"showSubtree": true},
"implementation": {
"kind": "text",
"source": {"equations": ["out = in + offset"], "parameters": {"offset": 0.0}}
}
},
{
"id": "example-c",
"name": "Block C",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}],
"outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}]
},
"icon": {"shape": "rectangle", "fill": "#fef3c7", "border": "#8a641c", "text": "C"},
"properties": {},
"library": {"showSubtree": true},
"implementation": {"kind": "graph", "graph": {"blocks": [], "connections": []}}
}
]
}

View File

@@ -0,0 +1,351 @@
{
"format": "bedit-document",
"version": 1,
"metadata": {
"name": "Untitled"
},
"roots": [
{
"id": "97cd3d0b-c36a-467e-b1f5-4c794979bd99",
"name": "something",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "in",
"name": "Input",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {}
}
],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Something"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": [],
"parameters": {}
}
}
},
{
"id": "614b4018-c00e-40ce-b59c-5d5795eab7d0",
"name": "Test",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Test"
},
"properties": {},
"library": {
"showSubtree": false
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [
{
"id": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce",
"name": "sin",
"position": {
"x": -28.0,
"y": -179.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "input-0bcb214f",
"name": "a",
"position": {
"x": -223.0,
"y": -161.0
},
"properties": {}
}
],
"outputs": [
{
"id": "output-a55cd289",
"name": "b",
"position": {
"x": 133.0,
"y": -152.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "sin()"
},
"properties": {},
"library": {
"showSubtree": false
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": [
{
"id": "cef30af9-fdc5-4a28-a7d6-4ab3c83e65d7",
"source": {
"interface": "input-0bcb214f"
},
"target": {
"interface": "output-a55cd289"
},
"name": "",
"properties": {}
}
]
}
}
},
{
"id": "88f40d88-f62b-432f-a6a0-95513b48409e",
"name": "constant",
"position": {
"x": -260.0,
"y": -178.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": [
{
"id": "output-1e15ff3f",
"name": "Output 1",
"position": {
"x": 71.0,
"y": -134.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "C"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": []
}
}
},
{
"id": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5",
"name": "New Text Block 1",
"position": {
"x": 225.0,
"y": -167.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "in",
"name": "Input",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {}
}
],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": [],
"parameters": {}
}
}
}
],
"connections": [
{
"id": "b3837790-26ba-46a6-91f9-3b6808c7ad1a",
"source": {
"block": "88f40d88-f62b-432f-a6a0-95513b48409e",
"port": "output-1e15ff3f"
},
"target": {
"block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce",
"port": "input-0bcb214f"
},
"name": "",
"properties": {}
},
{
"id": "50b2c7aa-d824-407c-ae0f-6b3bb4b1b79e",
"source": {
"block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce",
"port": "output-a55cd289"
},
"target": {
"block": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5",
"port": "in"
},
"name": "",
"properties": {}
}
]
}
}
},
{
"id": "465796f5-9075-45f6-81b9-7fa17476392c",
"name": "sin",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "input-0bcb214f",
"name": "Input 1",
"position": {
"x": -223.0,
"y": -161.0
},
"properties": {}
}
],
"outputs": [
{
"id": "output-a55cd289",
"name": "Output 1",
"position": {
"x": 133.0,
"y": -152.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "sin()"
},
"properties": {},
"library": {
"showSubtree": false
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": [
{
"id": "029ccfaa-7150-4a53-beec-286ff8e2a628",
"source": {
"interface": "input-0bcb214f"
},
"target": {
"interface": "output-a55cd289"
},
"name": "",
"properties": {}
}
]
}
}
},
{
"id": "bb0adff3-baf2-469b-9a80-f05910d0f259",
"name": "constant",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": [
{
"id": "output-1e15ff3f",
"name": "Output 1",
"position": {
"x": 71.0,
"y": -134.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "C"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": []
}
}
}
]
}

View File

@@ -0,0 +1,13 @@
from bedit.document.controller import DocumentController
from bedit.document.model import Component, Connection, Endpoint, Graph, GraphDocument, Icon, Port
__all__ = [
"Component",
"Connection",
"DocumentController",
"Endpoint",
"Graph",
"GraphDocument",
"Icon",
"Port",
]

View File

@@ -0,0 +1,262 @@
from PySide6.QtCore import QPointF
from PySide6.QtGui import QUndoCommand
from bedit.document.model import Component, Connection, Port
class AddComponentCommand(QUndoCommand):
def __init__(self, controller, owner_id: str | None, component: Component) -> None:
super().__init__(f"Add {component.name}")
self.controller = controller
self.owner_id = owner_id
self.component = component
def redo(self) -> None:
self.controller._insert_component(self.owner_id, self.component)
def undo(self) -> None:
self.controller._remove_component(self.owner_id, self.component.id)
class MoveComponentCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
component_id: str,
old: QPointF,
new: QPointF,
) -> None:
super().__init__("Move component")
self.controller = controller
self.owner_id = owner_id
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._move_component(self.owner_id, self.component_id, self.new)
def undo(self) -> None:
self.controller._move_component(self.owner_id, self.component_id, self.old)
class RotateComponentsCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
rotations: dict[str, tuple[float, float]],
) -> None:
super().__init__("Rotate components")
self.controller = controller
self.owner_id = owner_id
self.rotations = rotations
def redo(self) -> None:
for component_id, (_old, new) in self.rotations.items():
self.controller._rotate_component(self.owner_id, component_id, new)
def undo(self) -> None:
for component_id, (old, _new) in self.rotations.items():
self.controller._rotate_component(self.owner_id, component_id, old)
class AddConnectionCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, connection: Connection) -> None:
super().__init__("Connect components")
self.controller = controller
self.owner_id = owner_id
self.connection = connection
def redo(self) -> None:
self.controller._insert_connection(self.owner_id, self.connection)
def undo(self) -> None:
self.controller._remove_connection(self.owner_id, self.connection.id)
class ReplaceComponentCommand(QUndoCommand):
def __init__(self, controller, old: Component, new: Component) -> None:
super().__init__("Apply JSON changes")
self.controller = controller
self.old = old
self.new = new
def redo(self) -> None:
self.controller._replace_component(self.old.id, self.new)
def undo(self) -> None:
self.controller._replace_component(self.new.id, self.old)
class AddInterfacePortCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, direction: str, port: Port) -> None:
super().__init__(f"Add {direction}")
self.controller = controller
self.owner_id = owner_id
self.direction = direction
self.port = port
def redo(self) -> None:
self.controller._insert_interface_port(self.owner_id, self.direction, self.port)
def undo(self) -> None:
self.controller._remove_interface_port(self.owner_id, self.direction, self.port.id)
class ReplaceSourceCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict) -> None:
super().__init__("Apply source JSON")
self.controller = controller
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._replace_source(self.component_id, self.new)
def undo(self) -> None:
self.controller._replace_source(self.component_id, self.old)
class MoveInterfacePortCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, port_id: str, old: QPointF, new: QPointF) -> None:
super().__init__("Move interface terminal")
self.controller = controller
self.owner_id = owner_id
self.port_id = port_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._move_interface_port(self.owner_id, self.port_id, self.new)
def undo(self) -> None:
self.controller._move_interface_port(self.owner_id, self.port_id, self.old)
class EditComponentAppearanceCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict) -> None:
super().__init__("Edit component appearance")
self.controller = controller
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._set_component_appearance(self.component_id, self.new)
def undo(self) -> None:
self.controller._set_component_appearance(self.component_id, self.old)
class RenameInterfacePortCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, port_id: str, old: str, new: str) -> None:
super().__init__("Rename interface port")
self.controller, self.owner_id, self.port_id = controller, owner_id, port_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._rename_interface_port(self.owner_id, self.port_id, self.new)
def undo(self) -> None:
self.controller._rename_interface_port(self.owner_id, self.port_id, self.old)
class RenameConnectionCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, connection_id: str, old: str, new: str) -> None:
super().__init__("Rename connection")
self.controller, self.owner_id, self.connection_id = controller, owner_id, connection_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._rename_connection(self.owner_id, self.connection_id, self.new)
def undo(self) -> None:
self.controller._rename_connection(self.owner_id, self.connection_id, self.old)
class DeleteSelectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
) -> None:
super().__init__("Delete selection")
self.controller = controller
self.owner_id = owner_id
self.blocks = blocks
self.connections = connections
self.inputs = inputs
self.outputs = outputs
def redo(self) -> None:
self.controller._delete_items(
self.owner_id,
set(self.blocks),
set(self.connections),
{port.id for port in self.inputs},
{port.id for port in self.outputs},
)
def undo(self) -> None:
self.controller._restore_items(
self.owner_id,
self.blocks,
self.connections,
self.inputs,
self.outputs,
)
class PasteSelectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
blocks: dict[str, Component],
connections: dict[str, Connection],
) -> None:
super().__init__("Paste selection")
self.controller = controller
self.owner_id = owner_id
self.blocks = blocks
self.connections = connections
def redo(self) -> None:
self.controller._restore_items(
self.owner_id,
self.blocks,
self.connections,
[],
[],
)
def undo(self) -> None:
self.controller._delete_items(
self.owner_id,
set(self.blocks),
set(self.connections),
set(),
set(),
)
class EditTextDefinitionCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict) -> None:
super().__init__("Edit text component")
self.controller = controller
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._set_text_definition(self.component_id, self.new)
def undo(self) -> None:
self.controller._set_text_definition(self.component_id, self.old)

View File

@@ -0,0 +1,673 @@
from copy import deepcopy
from pathlib import Path
from uuid import uuid4
from PySide6.QtCore import QObject, QPointF, Signal
from PySide6.QtGui import QUndoStack
from bedit.document.commands import (
AddComponentCommand,
AddConnectionCommand,
AddInterfacePortCommand,
DeleteSelectionCommand,
EditTextDefinitionCommand,
EditComponentAppearanceCommand,
MoveComponentCommand,
MoveInterfacePortCommand,
PasteSelectionCommand,
RenameConnectionCommand,
RenameInterfacePortCommand,
ReplaceSourceCommand,
RotateComponentsCommand,
)
from bedit.document.model import (
Component,
Connection,
Endpoint,
GraphDocument,
Icon,
Port,
clone_component,
)
from bedit.document.serializer import JsonDocumentSerializer
class DocumentController(QObject):
documentReset = Signal()
documentOpenedChanged = Signal(bool)
activeGraphChanged = Signal()
componentAdded = Signal(str)
componentRemoved = Signal(str)
componentMoved = Signal(str, QPointF)
componentRotated = Signal(str, float)
connectionAdded = Signal(str)
connectionRemoved = Signal(str)
interfaceChanged = Signal()
filePathChanged = Signal(object)
modifiedChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.document: GraphDocument | None = None
self.active_component_id: str | None = None
self.file_path: Path | None = None
self.undo_stack = QUndoStack(self)
self.undo_stack.cleanChanged.connect(self._clean_changed)
def _clean_changed(self, clean: bool) -> None:
self.modifiedChanged.emit(not clean)
@property
def active_component(self) -> Component | None:
if self.document is None or self.active_component_id is None:
return None
return self.document.find_component(self.active_component_id)
@property
def active_graph(self):
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("There is no active graph")
return component.graph
def new_document(self) -> None:
self.document = GraphDocument.empty()
self.active_component_id = None
self.file_path = None
self.undo_stack.clear()
self.documentOpenedChanged.emit(True)
self.documentReset.emit()
self.activeGraphChanged.emit()
self.filePathChanged.emit(None)
def close_document(self) -> None:
self.document = None
self.active_component_id = None
self.file_path = None
self.undo_stack.clear()
self.documentOpenedChanged.emit(False)
self.documentReset.emit()
self.activeGraphChanged.emit()
self.filePathChanged.emit(None)
def load(self, path: Path) -> None:
self.document = JsonDocumentSerializer.load(path)
self.active_component_id = next(iter(self.document.roots), None)
self.file_path = path
self.undo_stack.clear()
self.undo_stack.setClean()
self.documentOpenedChanged.emit(True)
self.documentReset.emit()
self.activeGraphChanged.emit()
self.filePathChanged.emit(path)
def save(self, path: Path | None = None) -> Path:
if self.document is None:
raise ValueError("There is no open document")
target = path or self.file_path
if target is None:
raise ValueError("No file path has been selected")
JsonDocumentSerializer.save(self.document, target)
self.file_path = target
self.undo_stack.setClean()
self.filePathChanged.emit(target)
return target
def activate_component(self, component_id: str) -> None:
if self.document is None or self.document.find_component(component_id) is None:
return
self.active_component_id = component_id
self.activeGraphChanged.emit()
def navigate_up(self) -> None:
if self.document is None or self.active_component_id is None:
return
parent = self.document.find_parent(self.active_component_id)
if parent is not None:
self.activate_component(parent.id)
def breadcrumb(self) -> list[str]:
if self.document is None or self.active_component is None:
return []
names = [self.active_component.name]
current = self.active_component
while True:
parent = self.document.find_parent(current.id)
if parent is None:
break
names.append(parent.name)
current = parent
return list(reversed(names))
def add_root(self, kind: str) -> str:
if self.document is None:
raise ValueError("Open or create a document first")
number = len(self.document.roots) + 1
component = Component(
id=str(uuid4()),
name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}",
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": [], "parameters": {}} if kind == "text" else {},
)
self.undo_stack.push(AddComponentCommand(self, None, component))
self.activate_component(component.id)
return component.id
def add_child(self, owner_id: str, kind: str) -> str:
if self.document is None:
raise ValueError("Open or create a document first")
owner = self.document.find_component(owner_id)
if owner is None or owner.implementation_kind != "graph":
raise ValueError("Children can only be added to graph components")
number = len(owner.graph.blocks) + 1
component = Component(
id=str(uuid4()),
name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}",
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": [], "parameters": {}} if kind == "text" else {},
)
self.undo_stack.push(AddComponentCommand(self, owner_id, component))
return component.id
def delete_component(self, component_id: str) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
parent = self.document.find_parent(component_id)
owner_id = parent.id if parent else None
connections = {}
if parent is not None:
connections = {
connection.id: connection
for connection in parent.graph.connections.values()
if component_id in (connection.source.block, connection.target.block)
}
self.undo_stack.push(
DeleteSelectionCommand(
self,
owner_id,
{component_id: component},
connections,
[],
[],
)
)
def add_component_copy(self, source: Component, position: QPointF) -> str:
if self.active_component is None or self.active_component.implementation_kind != "graph":
raise ValueError("Open a graph component before placing components")
component = clone_component(source)
component.x, component.y = position.x(), position.y()
self.undo_stack.push(AddComponentCommand(self, self.active_component_id, component))
return component.id
def move_component(self, component_id: str, old: QPointF, new: QPointF) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
MoveComponentCommand(self, self.active_component_id, component_id, old, new)
)
def rotate_components(self, component_ids: set[str]) -> None:
if self.active_component is None or self.active_component_id is None:
return
rotations = {
component_id: (component.rotation, (component.rotation + 90.0) % 360.0)
for component_id in component_ids
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)
)
def connect(self, source: Endpoint, target: Endpoint) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
connection = Connection(str(uuid4()), source, target)
self.undo_stack.push(
AddConnectionCommand(self, self.active_component_id, connection)
)
return connection.id
def add_interface_port(self, direction: str, position: QPointF) -> str:
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before adding an interface")
ports = component.inputs if direction == "input" else component.outputs
port = Port(
id=f"{direction}-{uuid4().hex[:8]}",
name=f"{direction.title()} {len(ports) + 1}",
x=position.x(),
y=position.y(),
)
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:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
MoveInterfacePortCommand(self, self.active_component_id, port_id, old, new)
)
def rename_interface_port(self, port_id: str, name: str) -> None:
owner = self.active_component
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
if port is not None and port.name != name:
self.undo_stack.push(
RenameInterfacePortCommand(self, owner.id, port_id, port.name, name)
)
def rename_connection(self, connection_id: str, name: str) -> None:
owner = self.active_component
if owner is None or owner.implementation_kind != "graph":
return
connection = owner.graph.connections.get(connection_id)
if connection is not None and connection.name != name:
self.undo_stack.push(
RenameConnectionCommand(self, owner.id, connection_id, connection.name, name)
)
def replace_active_source(self, source: dict) -> None:
component = self.active_component
if component is None or component.implementation_kind != "text":
raise ValueError("Only text-defined components have source JSON")
self.undo_stack.push(
ReplaceSourceCommand(
self,
component.id,
deepcopy(component.source),
deepcopy(source),
)
)
def replace_active_text_definition(
self,
inputs: list[Port],
outputs: list[Port],
source: dict,
) -> None:
component = self.active_component
if component is None or component.implementation_kind != "text":
raise ValueError("Only text-defined components can be edited here")
input_ids = [port.id for port in inputs]
output_ids = [port.id for port in outputs]
if len(set(input_ids)) != len(input_ids) or len(set(output_ids)) != len(output_ids):
raise ValueError("Input and output IDs must be unique")
if self.document is not None:
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:
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:
raise ValueError(
f"Output {connection.source.port!r} is still connected in the containing graph"
)
old = {
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"source": deepcopy(component.source),
}
new = {
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"source": deepcopy(source),
}
if old != new:
self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new))
def edit_component_appearance(
self,
component_id: str,
name: str,
shape: str,
fill: str,
border: str,
text: str,
show_subtree: bool,
) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
old = {
"name": component.name,
**component.icon.to_dict(),
"show_subtree": component.show_subtree_in_library,
}
new = {
"name": name,
"shape": shape,
"fill": fill,
"border": border,
"text": text,
"show_subtree": show_subtree,
}
if old != new:
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
def delete_selection(
self,
block_ids: set[str],
connection_ids: set[str],
input_ids: set[str],
output_ids: set[str],
) -> None:
component = self.active_component
if component is None or component.implementation_kind != "graph":
return
graph = component.graph
all_connection_ids = set(connection_ids)
for connection in graph.connections.values():
if (
connection.source.block in block_ids
or connection.target.block in block_ids
or connection.source.interface in input_ids
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}
connections = {
connection_id: graph.connections[connection_id]
for connection_id in all_connection_ids
if connection_id in graph.connections
}
inputs = [port for port in component.inputs if port.id in input_ids]
outputs = [port for port in component.outputs if port.id in output_ids]
if not (blocks or connections or inputs or outputs):
return
self.undo_stack.push(
DeleteSelectionCommand(
self,
component.id,
blocks,
connections,
inputs,
outputs,
)
)
def paste_selection(
self,
source_components: list[Component],
source_connections: list[Connection],
offset: QPointF,
) -> list[str]:
owner = self.active_component
if owner is None or owner.implementation_kind != "graph":
return []
pairs = [(source, clone_component(source)) for source in source_components]
id_map = {source.id: clone.id for source, clone in pairs}
blocks = {}
for _source, clone in pairs:
clone.x += offset.x()
clone.y += offset.y()
blocks[clone.id] = clone
connections = {}
for source in source_connections:
if source.source.block not in id_map or source.target.block not in id_map:
continue
connection = Connection(
id=str(uuid4()),
source=Endpoint(block=id_map[source.source.block], port=source.source.port),
target=Endpoint(block=id_map[source.target.block], port=source.target.port),
)
connections[connection.id] = connection
if blocks:
self.undo_stack.push(
PasteSelectionCommand(self, owner.id, blocks, connections)
)
return list(blocks)
def _graph_for(self, owner_id: str):
if self.document is None:
raise ValueError("There is no open document")
owner = self.document.find_component(owner_id)
if owner is None:
raise ValueError("The containing component is no longer in the document")
return owner.graph
def _insert_component(self, owner_id: str | None, component: Component) -> None:
if self.document is None:
raise ValueError("There is no open document")
if owner_id is None:
self.document.roots[component.id] = component
else:
self._graph_for(owner_id).blocks[component.id] = component
if owner_id == self.active_component_id:
self.componentAdded.emit(component.id)
self.documentReset.emit()
def _remove_component(self, owner_id: str | None, component_id: str) -> None:
if self.document is None:
return
if owner_id is None:
self.document.roots.pop(component_id, None)
if self.active_component_id == component_id:
self.active_component_id = next(iter(self.document.roots), None)
self.activeGraphChanged.emit()
else:
self._graph_for(owner_id).blocks.pop(component_id, None)
if owner_id == self.active_component_id:
self.componentRemoved.emit(component_id)
self.documentReset.emit()
def _move_component(self, owner_id: str, component_id: str, position: QPointF) -> None:
component = self._graph_for(owner_id).blocks[component_id]
component.x, component.y = position.x(), position.y()
if owner_id == self.active_component_id:
self.componentMoved.emit(component_id, position)
self.documentReset.emit()
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
component.rotation = rotation
if owner_id == self.active_component_id:
self.componentRotated.emit(component_id, rotation)
def _insert_connection(self, owner_id: str, connection: Connection) -> None:
self._graph_for(owner_id).connections[connection.id] = connection
if owner_id == self.active_component_id:
self.connectionAdded.emit(connection.id)
self.documentReset.emit()
def _remove_connection(self, owner_id: str, connection_id: str) -> None:
self._graph_for(owner_id).connections.pop(connection_id, None)
if owner_id == self.active_component_id:
self.connectionRemoved.emit(connection_id)
self.documentReset.emit()
def _replace_component(self, old_id: str, replacement: Component) -> None:
if self.document is None:
return
was_active = self.active_component_id == old_id
if old_id in self.document.roots:
self.document.roots.pop(old_id)
self.document.roots[replacement.id] = replacement
else:
parent = self.document.find_parent(old_id)
if parent is None:
raise ValueError("The component is no longer in this document")
parent.graph.blocks.pop(old_id)
parent.graph.blocks[replacement.id] = replacement
if was_active:
self.active_component_id = replacement.id
self.document.validate()
self.documentReset.emit()
if was_active:
self.activeGraphChanged.emit()
def _insert_interface_port(self, owner_id: str, direction: str, port: Port) -> None:
if self.document is None:
return
owner = self.document.find_component(owner_id)
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
if all(existing.id != port.id for existing in ports):
ports.append(port)
self.interfaceChanged.emit()
self.documentReset.emit()
def _remove_interface_port(self, owner_id: str, direction: str, port_id: str) -> None:
if self.document is None:
return
owner = self.document.find_component(owner_id)
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
ports[:] = [port for port in ports if port.id != port_id]
self.interfaceChanged.emit()
self.documentReset.emit()
def _move_interface_port(self, owner_id: str, port_id: str, position: QPointF) -> None:
if self.document is None:
return
owner = self.document.find_component(owner_id)
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
if port is not None:
port.x, port.y = position.x(), position.y()
self.interfaceChanged.emit()
self.documentReset.emit()
def _rename_interface_port(self, owner_id: str, port_id: str, name: str) -> None:
owner = self.document.find_component(owner_id) if self.document else None
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
if port is not None:
port.name = name
self.interfaceChanged.emit()
self.documentReset.emit()
def _rename_connection(self, owner_id: str, connection_id: str, name: str) -> None:
connection = self._graph_for(owner_id).connections.get(connection_id)
if connection is not None:
connection.name = name
self.documentReset.emit()
def _replace_source(self, component_id: str, source: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
component.source = deepcopy(source)
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()
def _set_component_appearance(self, component_id: str, values: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
component.name = values["name"]
component.icon = Icon(
shape=values["shape"],
fill=values["fill"],
border=values["border"],
text=values["text"],
)
component.show_subtree_in_library = values["show_subtree"]
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()
def _delete_items(
self,
owner_id: str | None,
block_ids: set[str],
connection_ids: set[str],
input_ids: set[str],
output_ids: set[str],
) -> None:
if self.document is None:
return
deleted_component_ids: set[str] = set()
for block_id in block_ids:
component = self.document.find_component(block_id)
if component is not None:
deleted_component_ids.update(
child.id for child in self._component_subtree(component)
)
active_was_deleted = self.active_component_id in deleted_component_ids
if owner_id is None:
for block_id in block_ids:
self.document.roots.pop(block_id, None)
else:
owner = self.document.find_component(owner_id)
if owner is None:
return
for block_id in block_ids:
owner.graph.blocks.pop(block_id, None)
for connection_id in connection_ids:
owner.graph.connections.pop(connection_id, None)
owner.inputs[:] = [port for port in owner.inputs if port.id not in input_ids]
owner.outputs[:] = [port for port in owner.outputs if port.id not in output_ids]
if active_was_deleted:
self.active_component_id = owner_id or next(iter(self.document.roots), None)
self.activeGraphChanged.emit()
self.documentReset.emit()
def _restore_items(
self,
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
) -> None:
if self.document is None:
return
if owner_id is None:
self.document.roots.update(blocks)
else:
owner = self.document.find_component(owner_id)
if owner is None:
return
owner.graph.blocks.update(blocks)
owner.graph.connections.update(connections)
existing_inputs = {port.id for port in owner.inputs}
existing_outputs = {port.id for port in owner.outputs}
owner.inputs.extend(port for port in inputs if port.id not in existing_inputs)
owner.outputs.extend(port for port in outputs if port.id not in existing_outputs)
self.documentReset.emit()
@staticmethod
def _component_subtree(component: Component):
yield component
for child in component.graph.blocks.values():
yield from DocumentController._component_subtree(child)
def _set_text_definition(self, component_id: str, values: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
component.inputs = [Port.from_dict(item) for item in values["inputs"]]
component.outputs = [Port.from_dict(item) for item in values["outputs"]]
component.source = deepcopy(values["source"])
self.interfaceChanged.emit()
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()

View File

@@ -0,0 +1,329 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
@dataclass
class Port:
id: str
name: str
x: float = 0.0
y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"properties": self.properties,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Port":
position = data.get("position", {})
return cls(
id=str(data["id"]),
name=str(data.get("name", data["id"])),
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
properties=dict(data.get("properties", {})),
)
@dataclass
class Icon:
shape: str = "rectangle"
fill: str = "#f4f4f4"
border: str = "#303030"
text: str = ""
def to_dict(self) -> dict[str, str]:
return {
"shape": self.shape,
"fill": self.fill,
"border": self.border,
"text": self.text,
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {}
return cls(
shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")),
text=str(data.get("text", "")),
)
@dataclass(frozen=True)
class Endpoint:
block: str | None = None
port: str | None = None
interface: str | None = None
def to_dict(self) -> dict[str, str]:
if self.interface is not None:
return {"interface": self.interface}
if self.block is None or self.port is None:
raise ValueError("A block endpoint requires both block and port")
return {"block": self.block, "port": self.port}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Endpoint":
if "interface" in data:
return cls(interface=str(data["interface"]))
return cls(block=str(data["block"]), port=str(data["port"]))
@dataclass
class Connection:
id: str
source: Endpoint
target: Endpoint
name: str = ""
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"source": self.source.to_dict(),
"target": self.target.to_dict(),
"name": self.name,
"properties": self.properties,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Connection":
return cls(
id=str(data["id"]),
source=Endpoint.from_dict(data["source"]),
target=Endpoint.from_dict(data["target"]),
name=str(data.get("name", "")),
properties=dict(data.get("properties", {})),
)
@dataclass
class Graph:
blocks: dict[str, Component] = field(default_factory=dict)
connections: dict[str, Connection] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"blocks": [block.to_dict() for block in self.blocks.values()],
"connections": [connection.to_dict() for connection in self.connections.values()],
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Graph":
data = data or {}
blocks = [Component.from_dict(item) for item in data.get("blocks", [])]
connections = [Connection.from_dict(item) for item in data.get("connections", [])]
if len({block.id for block in blocks}) != len(blocks):
raise ValueError("A graph contains duplicate component IDs")
if len({connection.id for connection in connections}) != len(connections):
raise ValueError("A graph contains duplicate connection IDs")
return cls(
blocks={block.id: block for block in blocks},
connections={connection.id: connection for connection in connections},
)
@dataclass
class Component:
id: str
name: str
x: float = 0.0
y: float = 0.0
rotation: float = 0.0
inputs: list[Port] = field(default_factory=list)
outputs: list[Port] = field(default_factory=list)
icon: Icon = field(default_factory=Icon)
properties: dict[str, Any] = field(default_factory=dict)
implementation_kind: str = "graph"
graph: Graph = field(default_factory=Graph)
source: dict[str, Any] = field(default_factory=dict)
show_subtree_in_library: bool = True
def to_dict(self) -> dict[str, Any]:
implementation = {"kind": self.implementation_kind}
if self.implementation_kind == "text":
implementation["source"] = self.source
else:
implementation["graph"] = self.graph.to_dict()
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"rotation": self.rotation,
"interface": {
"inputs": [port.to_dict() for port in self.inputs],
"outputs": [port.to_dict() for port in self.outputs],
},
"icon": self.icon.to_dict(),
"properties": self.properties,
"library": {"showSubtree": self.show_subtree_in_library},
"implementation": implementation,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Component":
interface = data.get("interface", {})
position = data.get("position", {})
implementation = data["implementation"]
kind = str(implementation.get("kind", "graph"))
if kind not in {"graph", "text"}:
raise ValueError(f"Unknown component implementation kind: {kind}")
return cls(
id=str(data["id"]),
name=str(data.get("name", "Unnamed")),
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
rotation=float(data.get("rotation", 0.0)),
inputs=[Port.from_dict(item) for item in interface.get("inputs", [])],
outputs=[Port.from_dict(item) for item in interface.get("outputs", [])],
icon=Icon.from_dict(data.get("icon")),
properties=dict(data.get("properties", {})),
show_subtree_in_library=bool(data.get("library", {}).get("showSubtree", True)),
implementation_kind=kind,
graph=Graph.from_dict(implementation.get("graph")) if kind == "graph" else Graph(),
source=dict(implementation.get("source", {})) if kind == "text" else {},
)
@dataclass
class GraphDocument:
roots: dict[str, Component] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def empty(cls) -> "GraphDocument":
return cls(metadata={"name": "Untitled"})
def to_dict(self) -> dict[str, Any]:
return {
"format": "bedit-document",
"version": 1,
"metadata": self.metadata,
"roots": [root.to_dict() for root in self.roots.values()],
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "GraphDocument":
if data.get("format") != "bedit-document":
raise ValueError("This is not a BEdit document")
if data.get("version") != 1:
raise ValueError(f"Unsupported BEdit document version: {data.get('version')}")
roots = [Component.from_dict(item) for item in data["roots"]]
if len({root.id for root in roots}) != len(roots):
raise ValueError("The document contains duplicate root IDs")
document = cls(
roots={root.id: root for root in roots},
metadata=dict(data.get("metadata", {})),
)
document.validate()
return document
def all_components(self):
def walk(component: Component):
yield component
if component.implementation_kind == "graph":
for child in component.graph.blocks.values():
yield from walk(child)
def all_roots():
for root in self.roots.values():
yield from walk(root)
return all_roots()
def find_component(self, component_id: str) -> Component | None:
return next(
(component for component in self.all_components() if component.id == component_id),
None,
)
def find_parent(self, component_id: str) -> Component | None:
for component in self.all_components():
if component_id in component.graph.blocks:
return component
return None
def validate(self) -> None:
seen: set[str] = set()
for component in self.all_components():
if component.id in seen:
raise ValueError(f"Duplicate component ID: {component.id}")
seen.add(component.id)
if component.implementation_kind == "text" and component.graph.blocks:
raise ValueError(f"Text component {component.name} cannot contain a graph")
self._validate_graph(component)
@staticmethod
def _validate_graph(owner: Component) -> None:
input_ids = {port.id for port in owner.inputs}
output_ids = {port.id for port in owner.outputs}
for connection in owner.graph.connections.values():
if connection.source.interface is not None:
if connection.source.interface not in input_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface input")
else:
source = owner.graph.blocks.get(connection.source.block or "")
if source is None or connection.source.port not in {p.id for p in source.outputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block output")
if connection.target.interface is not None:
if connection.target.interface not in output_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface output")
else:
target = owner.graph.blocks.get(connection.target.block or "")
if target is None or connection.target.port not in {p.id for p in target.inputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block input")
def clone_component(source: Component) -> Component:
"""Deep-copy a component tree and remap every owned object ID."""
def clone(current: Component) -> Component:
child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()]
child_ids = {old.id: new.id for old, new in child_pairs}
def remap(endpoint: Endpoint) -> Endpoint:
if endpoint.interface is not None:
return endpoint
return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port)
graph = Graph(
blocks={new.id: new for _old, new in child_pairs},
connections={
new_id: Connection(
new_id,
remap(connection.source),
remap(connection.target),
connection.name,
deepcopy(connection.properties),
)
for connection in current.graph.connections.values()
for new_id in [str(uuid4())]
},
)
return Component(
id=str(uuid4()),
name=current.name,
x=current.x,
y=current.y,
inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs],
outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs],
icon=Icon(**current.icon.to_dict()),
properties=deepcopy(current.properties),
implementation_kind=current.implementation_kind,
graph=graph if current.implementation_kind == "graph" else Graph(),
source=deepcopy(current.source),
show_subtree_in_library=current.show_subtree_in_library,
)
return clone(source)

View File

@@ -0,0 +1,23 @@
import json
from pathlib import Path
from bedit.document.model import GraphDocument
class JsonDocumentSerializer:
@staticmethod
def load(path: Path) -> GraphDocument:
with path.open(encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, dict):
raise ValueError("The graph file must contain a JSON object")
return GraphDocument.from_dict(data)
@staticmethod
def save(document: GraphDocument, path: Path) -> None:
temporary_path = path.with_suffix(path.suffix + ".tmp")
with temporary_path.open("w", encoding="utf-8") as file:
json.dump(document.to_dict(), file, indent=2)
file.write("\n")
temporary_path.replace(path)

View File

@@ -0,0 +1,42 @@
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLineEdit,
QMessageBox,
QVBoxLayout,
)
class ItemOptionsDialog(QDialog):
"""Small, extensible options dialog shared by ports and connections."""
def __init__(self, title: str, name: str, parent=None, *, name_required: bool = True) -> None:
super().__init__(parent)
self.name_required = name_required
self.setWindowTitle(title)
self.resize(380, 120)
layout = QVBoxLayout(self)
self.form = QFormLayout()
self.name_edit = QLineEdit(name, self)
self.form.addRow("Name:", self.name_edit)
layout.addLayout(self.form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
@property
def name(self) -> str:
return self.name_edit.text().strip()
def accept(self) -> None:
if self.name_required and not self.name:
QMessageBox.warning(self, "Invalid name", "The name cannot be empty.")
return
super().accept()

View File

@@ -0,0 +1,3 @@
from bedit.library.repository import LibraryRepository, default_library_paths
__all__ = ["LibraryRepository", "default_library_paths"]

View File

@@ -0,0 +1,56 @@
import json
from dataclasses import dataclass
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from bedit.document.model import GraphDocument
@dataclass(frozen=True)
class LibraryDocument:
name: str
document: GraphDocument
source_path: str
def bundled_library_path() -> Path:
return Path(__file__).resolve().parents[1] / "data" / "libraries" / "example.json"
def default_library_paths() -> list[str]:
return [str(bundled_library_path())]
class LibraryRepository(QObject):
librariesChanged = Signal()
loadWarningsChanged = Signal(list)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.libraries: list[LibraryDocument] = []
self.load_warnings: list[str] = []
def load_paths(self, paths: list[str]) -> None:
libraries: list[LibraryDocument] = []
warnings: list[str] = []
for raw_path in paths:
path = Path(raw_path).expanduser()
candidates = sorted(path.glob("*.json")) if path.is_dir() else [path]
for candidate in candidates:
try:
libraries.append(self._load_file(candidate))
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error:
warnings.append(f"{candidate}: {error}")
self.libraries = libraries
self.load_warnings = warnings
self.librariesChanged.emit()
self.loadWarningsChanged.emit(warnings)
@staticmethod
def _load_file(path: Path) -> LibraryDocument:
with path.open(encoding="utf-8") as file:
data = json.load(file)
document = GraphDocument.from_dict(data)
name = str(document.metadata.get("name") or path.stem)
return LibraryDocument(name, document, str(path))

View File

@@ -0,0 +1,91 @@
import json
from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from bedit.document.controller import DocumentController
from bedit.document.model import Component
from bedit.library.repository import LibraryRepository
COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1
COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2
ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3
COMPONENT_MIME_TYPE = "application/x-bedit-component"
class LibraryTreeModel(QStandardItemModel):
rebuilt = Signal()
def __init__(
self,
repository: LibraryRepository,
controller: DocumentController,
parent=None,
) -> None:
super().__init__(parent)
self.repository = repository
self.controller = controller
repository.librariesChanged.connect(self.rebuild)
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.setHorizontalHeaderLabels(["Libraries"])
for library in self.repository.libraries:
root = QStandardItem(library.name)
root.setDragEnabled(False)
root.setToolTip(library.source_path)
for component in library.document.roots.values():
root.appendRow(self._component_item(component))
self.appendRow(root)
self.rebuilt.emit()
def _component_item(self, component: Component, current: bool = False) -> QStandardItem:
item = QStandardItem(component.name)
item.setEditable(False)
item.setData(component.to_dict(), COMPONENT_ROLE)
item.setData(component.id, COMPONENT_ID_ROLE)
item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE)
if component.show_subtree_in_library:
for child in component.graph.blocks.values():
item.appendRow(self._component_item(child, current=current))
return item
def mimeTypes(self) -> list[str]: # noqa: N802
return [COMPONENT_MIME_TYPE]
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802
mime_data = QMimeData()
for index in indexes:
component = index.data(COMPONENT_ROLE)
if component:
encoded = json.dumps(component).encode("utf-8")
mime_data.setData(COMPONENT_MIME_TYPE, QByteArray(encoded))
break
return mime_data
def supportedDragActions(self): # noqa: N802
return Qt.DropAction.CopyAction
class DocumentTreeModel(LibraryTreeModel):
def __init__(self, controller: DocumentController, parent=None) -> None:
QStandardItemModel.__init__(self, parent)
self.controller = controller
controller.documentReset.connect(self.rebuild)
controller.componentMoved.connect(lambda _component_id, _position: self.rebuild())
controller.connectionAdded.connect(lambda _connection_id: self.rebuild())
controller.connectionRemoved.connect(lambda _connection_id: self.rebuild())
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.setHorizontalHeaderLabels(["Document"])
if self.controller.document is not None:
current_root = QStandardItem("Current Document")
current_root.setDragEnabled(False)
current_root.setData("current-document", ITEM_KIND_ROLE)
for component in self.controller.document.roots.values():
current_root.appendRow(self._component_item(component, current=True))
self.appendRow(current_root)
self.rebuilt.emit()

View File

@@ -1,13 +1,27 @@
import json
from pathlib import Path
from PySide6.QtCore import QSettings, Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QMainWindow, QMessageBox
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox
from bedit.component_options_dialog import ComponentOptionsDialog
from bedit.document.controller import DocumentController
from bedit.document.model import Port
from bedit.item_options_dialog import ItemOptionsDialog
from bedit.library.repository import LibraryRepository
from bedit.library.tree_model import (
COMPONENT_ID_ROLE,
ITEM_KIND_ROLE,
DocumentTreeModel,
LibraryTreeModel,
)
from bedit.settings_dialog import SettingsDialog
from bedit.ui_main_window import Ui_MainWindow
class MainWindow(QMainWindow):
"""Application shell for the window laid out in Qt Designer."""
"""Application shell and owner of the single active document."""
def __init__(self) -> None:
super().__init__()
@@ -15,44 +29,447 @@ class MainWindow(QMainWindow):
self.ui.setupUi(self)
self.settings = QSettings()
self.libraries = LibraryRepository(self)
self.document_controller = DocumentController(self)
self.library_tree_model = LibraryTreeModel(
self.libraries,
self.document_controller,
self,
)
self.document_tree_model = DocumentTreeModel(self.document_controller, self)
self._configure_models()
self._connect_actions()
self._populate_view_menu()
self.ui.actionExit.triggered.connect(self.close)
self.ui.actionSettings.triggered.connect(self.show_settings)
self.ui.actionAbout.triggered.connect(self.show_about)
self.ui.actionAboutQt.triggered.connect(lambda: QMessageBox.aboutQt(self, "About Qt Framework"))
self._restore_window_geometry()
self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget)
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.workspaceSplitter.setSizes([280, 720])
self.reload_libraries()
self._active_graph_changed()
self._update_title()
def _configure_models(self) -> None:
self.ui.treeView.setModel(self.library_tree_model)
self.ui.treeView.setHeaderHidden(True)
self.ui.treeView.setDragEnabled(True)
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
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.doubleClicked.connect(self.activate_tree_component)
self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
self.ui.graphView.set_model(self.document_controller)
self.ui.graphView.componentOptionsRequested.connect(self.show_component_options)
self.ui.graphView.portOptionsRequested.connect(self.show_port_options)
self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options)
self.ui.graphView.selectionAvailabilityChanged.connect(
lambda _available: self._update_edit_actions()
)
self.ui.navigateUpButton.clicked.connect(self.navigate_up)
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input"))
self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output"))
self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer"))
self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
def _connect_actions(self) -> None:
self.ui.actionNew.triggered.connect(self.new_document)
self.ui.actionOpen.triggered.connect(self.open_document)
self.ui.actionSave.triggered.connect(self.save_document)
self.ui.actionSaveAs.triggered.connect(self.save_document_as)
self.ui.actionClose.triggered.connect(self.close_document)
self.ui.actionExit.triggered.connect(self.close)
self.ui.actionSettings.triggered.connect(self.show_settings)
self.ui.actionAbout.triggered.connect(self.show_about)
self.ui.actionAboutQt.triggered.connect(
lambda: QMessageBox.aboutQt(self, "About Qt Framework")
)
self.ui.actionUndo.triggered.connect(self.document_controller.undo_stack.undo)
self.ui.actionRedo.triggered.connect(self.document_controller.undo_stack.redo)
self.ui.actionDelete.triggered.connect(self.ui.graphView.delete_selected)
self.ui.actionCopy.triggered.connect(self.ui.graphView.copy_selection)
self.ui.actionCut.triggered.connect(self.ui.graphView.cut_selection)
self.ui.actionPaste.triggered.connect(self.ui.graphView.paste_selection)
self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all)
self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected)
self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title())
self.document_controller.filePathChanged.connect(lambda _path: self._update_title())
self.document_controller.documentOpenedChanged.connect(self._document_opened_changed)
self.ui.actionUndo.setEnabled(False)
self.ui.actionRedo.setEnabled(False)
self._update_edit_actions()
self._document_opened_changed(False)
def _populate_view_menu(self) -> None:
"""Add checked show/hide actions for panels and toolbars."""
panels = (self.ui.panel_libraries,)
for panel in panels:
for panel in (self.ui.panel_document, self.ui.panel_libraries):
self.ui.menuPanels.addAction(panel.toggleViewAction())
toolbars = (self.ui.fileToolbar, self.ui.editToolbar)
for toolbar in toolbars:
for toolbar in (
self.ui.fileToolbar,
self.ui.editToolbar,
self.ui.transformToolbar,
):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
def reload_libraries(self) -> None:
self.libraries.load_paths(SettingsDialog.library_paths(self.settings))
self.ui.treeView.expandAll()
if self.libraries.load_warnings:
QMessageBox.warning(
self,
"Some libraries could not be loaded",
"\n".join(self.libraries.load_warnings),
)
def _restore_window_geometry(self) -> None:
geometry = self.settings.value("window/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
def _update_title(self) -> None:
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"
modified = "*" if not self.document_controller.undo_stack.isClean() else ""
self.setWindowTitle(f"{modified}{name} — BEdit")
def _active_graph_changed(self) -> None:
component = self.document_controller.active_component
if component is None:
self.ui.graphBreadcrumbLabel.setText("No component selected")
self.ui.navigateUpButton.setEnabled(False)
self.ui.workspaceModeLabel.setText("")
self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage)
self.ui.applyJsonButton.setVisible(False)
for button in (
self.ui.pointerToolButton,
self.ui.inputToolButton,
self.ui.outputToolButton,
):
button.setVisible(False)
self._update_edit_actions()
return
self.ui.graphBreadcrumbLabel.setText(" ".join(self.document_controller.breadcrumb()))
self.ui.navigateUpButton.setEnabled(
self.document_controller.document.find_parent(component.id) is not None
)
is_graph = component.implementation_kind == "graph"
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage)
self.ui.applyJsonButton.setVisible(not is_graph)
for button in (
self.ui.pointerToolButton,
self.ui.inputToolButton,
self.ui.outputToolButton,
):
button.setVisible(is_graph)
if is_graph:
self.set_graph_tool("pointer")
else:
self._load_source_json()
self._update_edit_actions()
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())
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.actionSelectAll.setEnabled(is_graph)
self.ui.actionPaste.setEnabled(is_graph)
@Slot()
def navigate_up(self) -> None:
if self._resolve_source_edits():
self.document_controller.navigate_up()
def set_graph_tool(self, mode: str) -> None:
self.ui.graphView.set_tool_mode(mode)
buttons = {
"pointer": self.ui.pointerToolButton,
"input": self.ui.inputToolButton,
"output": self.ui.outputToolButton,
}
buttons[mode].setChecked(True)
def _load_source_json(self) -> None:
component = self.document_controller.active_component
if component is None:
return
text = json.dumps(
{
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"source": component.source,
},
indent=2,
)
self.ui.jsonEditor.setPlainText(text)
self.ui.jsonEditor.document().setModified(False)
def _resolve_source_edits(self) -> bool:
component = self.document_controller.active_component
if (
component is None
or component.implementation_kind != "text"
or not self.ui.jsonEditor.document().isModified()
):
return True
answer = QMessageBox.question(
self,
"Apply text component changes?",
"The text component has unapplied input, output, or source changes.",
QMessageBox.StandardButton.Apply
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
)
if answer == QMessageBox.StandardButton.Apply:
return self.apply_json()
return answer == QMessageBox.StandardButton.Discard
@Slot()
def apply_json(self) -> bool:
try:
data = json.loads(self.ui.jsonEditor.toPlainText())
if not isinstance(data, dict):
raise ValueError("The text component JSON must be an object")
if not isinstance(data.get("inputs"), list):
raise ValueError("'inputs' must be a list")
if not isinstance(data.get("outputs"), list):
raise ValueError("'outputs' must be a list")
if not isinstance(data.get("source"), dict):
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"]
)
except (TypeError, ValueError, json.JSONDecodeError) as error:
QMessageBox.critical(self, "Invalid text component JSON", str(error))
return False
self._load_source_json()
return True
def _maybe_save(self) -> bool:
if self.document_controller.document is None:
return True
if self.document_controller.undo_stack.isClean():
return True
answer = QMessageBox.warning(
self,
"Unsaved changes",
"The current graph contains unsaved changes.",
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
)
if answer == QMessageBox.StandardButton.Save:
return self.save_document()
return answer == QMessageBox.StandardButton.Discard
@Slot()
def new_document(self) -> None:
if self._resolve_source_edits() and self._maybe_save():
self.document_controller.new_document()
@Slot()
def close_document(self) -> None:
if self._resolve_source_edits() and self._maybe_save():
self.document_controller.close_document()
@Slot()
def open_document(self) -> None:
if not self._resolve_source_edits() or not self._maybe_save():
return
filename, _ = QFileDialog.getOpenFileName(
self, "Open graph", "", "BEdit graphs (*.bedit.json *.json);;All files (*)"
)
if not filename:
return
try:
self.document_controller.load(Path(filename))
except (OSError, ValueError) as error:
QMessageBox.critical(self, "Could not open graph", str(error))
@Slot()
def save_document(self) -> bool:
if self.document_controller.document is None:
return False
if self.document_controller.file_path is None:
return self.save_document_as()
try:
self.document_controller.save()
except OSError as error:
QMessageBox.critical(self, "Could not save graph", str(error))
return False
return True
@Slot()
def save_document_as(self) -> bool:
if self.document_controller.document is None:
return False
filename, _ = QFileDialog.getSaveFileName(
self,
"Save graph",
"untitled.bedit.json",
"BEdit graphs (*.bedit.json);;JSON files (*.json);;All files (*)",
)
if not filename:
return False
try:
self.document_controller.save(Path(filename))
except OSError as error:
QMessageBox.critical(self, "Could not save graph", str(error))
return False
return True
@Slot()
def show_settings(self) -> None:
SettingsDialog(self).exec()
dialog = SettingsDialog(self)
dialog.settingsChanged.connect(self.reload_libraries)
dialog.exec()
def _document_opened_changed(self, opened: bool) -> None:
for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs):
action.setEnabled(opened)
self._active_graph_changed()
@Slot(object)
def activate_tree_component(self, index) -> None:
if index.data(ITEM_KIND_ROLE) != "current-component":
return
component_id = index.data(COMPONENT_ID_ROLE)
if component_id:
self.document_controller.activate_component(component_id)
@Slot(object)
def show_library_context_menu(self, position) -> None:
tree_view = self.ui.documentTreeView
index = tree_view.indexAt(position)
kind = index.data(ITEM_KIND_ROLE)
if kind == "current-component":
component_id = index.data(COMPONENT_ID_ROLE)
document = self.document_controller.document
component = document.find_component(component_id) if document else None
if component is None:
return
menu = QMenu(self)
graph_action = None
text_action = None
if component.implementation_kind == "graph":
graph_action = menu.addAction("New Graph Block")
text_action = menu.addAction("New Text Block")
menu.addSeparator()
options_action = menu.addAction("Component Options…")
delete_action = menu.addAction("Delete")
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if selected is graph_action:
self.document_controller.add_child(component_id, "graph")
elif selected is text_action:
self.document_controller.add_child(component_id, "text")
elif selected is options_action:
self.show_component_options(component_id)
elif selected is delete_action:
answer = QMessageBox.question(
self,
"Delete component?",
f"Delete {component.name!r} and all of its contents?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
self.document_controller.delete_component(component_id)
return
if kind != "current-document":
return
menu = QMenu(self)
graph_action = menu.addAction("New Graph Block")
text_action = menu.addAction("New Text Block")
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if selected is graph_action:
self.document_controller.add_root("graph")
elif selected is text_action:
self.document_controller.add_root("text")
@Slot(str)
def show_component_options(self, component_id: str) -> None:
document = self.document_controller.document
component = document.find_component(component_id) if document else None
if component is None:
return
dialog = ComponentOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.edit_component_appearance(
component_id,
dialog.ui.nameEdit.text().strip(),
dialog.ui.shapeCombo.currentText(),
dialog.ui.fillEdit.text(),
dialog.ui.borderEdit.text(),
dialog.ui.iconTextEdit.text(),
dialog.ui.showSubtreeCheckBox.isChecked(),
)
@Slot(str, str)
def show_port_options(self, port_id: str, direction: str) -> None:
owner = self.document_controller.active_component
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
port = next((item for item in ports if item.id == port_id), None)
if port is None:
return
dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.rename_interface_port(port_id, dialog.name)
@Slot(str)
def show_connection_options(self, connection_id: str) -> None:
owner = self.document_controller.active_component
if owner is None or owner.implementation_kind != "graph":
return
connection = owner.graph.connections.get(connection_id)
if connection is None:
return
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)
@Slot()
def show_about(self) -> None:
QMessageBox.about(
self,
"About BEdit",
"<h3>BEdit</h3><p>A starter desktop application built with Python and Qt.</p>",
"<h3>BEdit</h3><p>A graphical editor built with Python and Qt.</p>",
)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name)
if not self._resolve_source_edits() or not self._maybe_save():
event.ignore()
return
self.settings.setValue("window/geometry", self.saveGeometry())
event.accept()

View File

@@ -234,6 +234,114 @@ _H\xaed[3B\xe2\xfa3\xb9\x04\xc0\xec\xee\xee\
\xe2\xbf\x0f\x0f\x0f\xa7)sR\xf6\xffA\x01\x8e9D\
R\xff\x03\x8c\xc5\xeaCX+lK\x00\x00\x00\x00I\
END\xaeB`\x82\
\x00\x00\x06\x9c\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\
\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\
\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\
\x00\x00\x09pHYs\x00\x00\x06\xec\x00\x00\x06\xec\x01\
\x1eu85\x00\x00\x00\x07tIME\x07\xd9\x06\x11\
\x17\x13$\xda\x852\xab\x00\x00\x06\x1cIDATx\
\xda\xed\x95}\x8c\x15\xd5\x19\xc6\x9f\xf7\xcc\xdc;\xb3w\
?\xfd\xa0T\xd8\x22+$(\xf2\xd5j\xc2\xae\x1f$\
\xadI\xff\xd0\xb8J(1%\x9a\x08%\xd6\x15-\xb6\
\x8d\xb6\xb1]H\x0bX\xa2\xaeUB\xac\xa1\xa9\xd1H\
\x1b\x14\xa4F\xa0\xc8*\x9a\x0a\xbaT\x5cL\x88\xc9\x92\
\x88\xd4\x82Z\xe1\xde;w\xef\x9d\xef\xf3\xf6\xcc\xb9\
\xc3d7J\x1a\xdc\xb4\x7f\xf1K\xde\xfb\xce\x99\xb9\x93\
\xe79\xef{\xce\x19\x9c\xe7<g\x18\x18x\x0fce\
\xeb\xd6\xad8W\x08#\xd8\xb7\xef\xbdW=\xcfk\x0d\
\x82\x10a\x18\xc0\xf7u\xd6\x11\x04\x91\xceQ$a\x1a\
\x04\xc34`\xaa \x12*\x9bd\xdbvm\xc1\x82[\
\xbe\x87s\xc4\xc4\x08\xa2(n\x9d6m\xca\x5c!\x04\
\x88\xe8K\x91\xcb\xe5`Y\x16ER@\xf9\x80\xc9\x1e\
\x930 \xa5\x0c_ye\xc7 \x14c2\x000'\
\xb3a\xe6Q\xc2\x0a\x9d\x13c\x8e+\xd1?\xc4\xb0L\
F[!O\x9d\x93\x81\x06;\x97cf\x8c\xd9\x003\
\xb4\x88\x94\xf2+g\x9f7\x09;\x0f\x09\x5c\xf9\x8d\x1a\
&\xb6J\x1c/\x9a\xd8t\xc0\xc2\xb7'\x09\x12\xf8z\
\x08\x8c\xe6+Ko\x18\x86\xceGO\x09\xd4\xdc\x10\xdf\
l\x8a\x10DP&\x22tO\xab\xe1\xb4\xe3\xf2\xe9K\
n\xe9\xda\xf0Vx;\x14O\xbd\xe1!a\xd56\x1f\
\x7fxK\xb6#\xe5\xc9\xdd\xfe\x7f7 \x84.\xb5\x8e\
\xb4\xec\xda\x80\x95\x13\xd8\xf6\x01an\xbb\x8b0f]\
.)\x81\x18\xc0\x9cKB\xea\x9e\xees\xb3\x15?\xff\
x\x7f0\x14\x89\xdc\x0c(zo\xb6P\xaeF\xeb\x1e\
\xed\x0f\xc2\xbe\xd7\xa2G\x97\xdf`\xe1\xe1\xbfyg\xdf\
\x05{\xf6\xec{g\xd6\xac+\xba\xd2\x16dFr\xa6\
\x81\xb7\x8f\x11>=\xe5c^\x87\xaf_\xa3\xc4(\x11\
\xb2\xff%m2\x81\x7f\x96\x0d~\xf3p\x8e\xaa\xa1\xd8\
\xdcd\xc8;?*\x81\x1b-^.@\xab\x0d\x81\xa2\
m\x19\x13\xcb\xc3Q\xb0z\xbe}\xb6\x16de\xcf\x22\
\x82\x81\x1d\x07\x19]\xdfr\x11K\x80\xa1\x82\xb9\x9e\xb3\
kF\x18\x13&4KZ<7D\xd7\xa5\xe1\xc2/\
\x5c\xae\xb6\xd9\xf8\xf9o\xbb\xed\xb5\x15_\xb6\x97\xdd\xd8\
<\xe5D\xbbS\xf1/\x1b0\x05\xa3\xe4%36\xb5\
\xb0\xa9\xb2m\x99xa?\xe3\xdaI5P\x22\xc3\xf5\
\xd0\xf0\xe8\x05L\xa4\x7f\x10\xc5\xc0w\xda\x19?\xfb.\
c\xca8^\xf5\xc0\x16\xafT0E\x87\xe3\xe1\x1a\xc7\
\xe3\xeb~\xf2Bp\xf9(\x03M\xcb\x8a\xc0\xb5\x5cX\
\xf4\xf2\xd4\xae\x9f\xbe\x18s\x83\xa5\xc5\xb5\x89\x13e\x81\
\xfd\xc7\x02\x5c5\xc1\x83\xcc\xc4\xd2\x0c\xceL\x10\x01`\
\xca\xae\x19\x04\x10c\xfe,\x89\x9e\xeb\xd1R\xf6y\xfb\
\x86;\x0aC\x8e\xcb'\xca5y\xeb(\x03\xcd\xb6\xe8\
\x1cwu\xc9q|\x93\xb7\xbe\x1f\xd3\xc7\xa7\x05\xcc\xb4\
\xfc}\xfd\x01\xe6O\xaf\x22\x88\xb5\xb0\x16\xcdLh\x11\
\xa4P6\x94\xcc*$H\xb7F\xe0\xe8\xbf\xa1\xde\xa7\
\xbdPT|\x0c*\x13\x933\x03W\xfc\xba\xf4@S\
\x1e\x03-6\x19M6\xd1\xf8V\xc2\x83/\x05 !\
\xf0\xf6\x87\x11\x02\xdfCG\x9b\x0f)\xf9\x8c\x03 3\
\xc1YH)\x11\xc7*T\x06\xa7\x0b\xd4\x10\x80 l\
|7\xa6r\x85\xef\xd6\x06j\x1cW<ie\x06\x0a\
y!\x1a-\xba\xb5\xa5@\x9d\x86 \xf4\xded\xf3\xee\
\xa1\x08G>\x8f\xd5\x96\x09\xb0\xf0\xca\x12j~\x8c0\
Tf\xd2\x08\x93\x88\x22%\x18kQ\xc9\x5c\x9f\xbd\xde\
\xc2\x89p\xfd@\xcb+\x03\xcf\x0dH\x0c{\xe8\xfb\xe0\
dxb\xe6\xca\xf2\xec\x92';\x9c\x9a<\x06\xc5\xf7\
\xfb\x9c\x1cA\xd1\xb9\xc6\x81iRO\xd5\x8d\xd6\x1fX\
\xd9&\xfe\xb4\xc7\xc7\xde\xc3\x11&4\xd6\xb0hv\x05\
L\x02\x86\xde\x92\x86\xfe\x10\x09C\x8dI\x8d\xd3kS\
\xdd'\x91\xe4\xfaX\x90\xd0\xff+\xba\xc4K\x9e\x8f\xbd\
]\xf77\x16&\xff\xc2yN@\xde\xa1\x1e\x07\xea\xf9\
6\xd3\xc4\x1c\x83\xd8\x17P\xec\xfbUK\xb2\x0e\x9a/\
n\xe4\x00\x8aE\x9dy\x1c\xfc$\xc6m3\x87!\x91\
\x8a\x89D\x10Z\x88\xce\x88\x8b\xfa3\xaaW\xba~_\
\x9b%4X\x02\x0f\xef\x94Dd,\x81\xc2\xf1\xc5\xbd\
\x8e\x07\xaax\x9cW-\xf8A\xa5&O\x1e|\xa70\
C \xa5\xb9A\xb8\x0dy\xc3\x84\xe2\xc1\xcd\x9e\x9ay\
U\x09@\x8b\x90\xa1\x82(5\xa2\xb2\x00H\x0b\xd7\xc7\
\x02i\x85\xf4\xfd\xfa;{\x8f0\xef\xff8\x1ezu\
\xb9\xbd\x09\x8a\xd3O4U\xaa>\xd69.h8\xa0\
U\xc7\x1fk\x9b7q\x96\xd2@J\x8b-\x06\x0b\x0d\
9\x13`\xde\xfb\xa1\xc45\x93\x036\xcdd\x86\x80H\
\x221\x91\x1d\xcf\xa9\x11\x8c0\x92\x1d\xdb\x02\xb6\x05\xf4\
\xbe\x1c\x12Ic\xe1u\x8f8Hh\xb8\xbb\x08\x90x\
\x88\xc9\xe8v\xd6\xb7\xae\x80\xe2_}m \x8c\xe0\xfe\
M._H\x9f\x1f\xbf\x98>s~\x7f`\xca\x8c\x9b\
\xa7\xc7Xv\xbd\x0fF:S#\xc9F\xd6\x7f\xf3\x8c\
\x11#\x19\x1b\xba\xfcy\x83\xf0\xcc@\x8c'^\x8f^\
<\xb4\xa6\xe56\xd4\xc9L\xb8O_p\xf6\x8fQ\xc1\
6\xef\x1d6\xc7O:\x84\xab\xfe|x\xedE\xb4\xf1\
}\xabw\xde\xfaf\xec:\x94C\xb3\x9d\xf6W\x97\x18\
i\xefU\xd4\x17]Z% \x90\x845;B\x84\xd2\
X<\xf3\x97%\x8c \x15\x1f\x0d\x01\xa3Y\xbd=\xf8\
\xa3\x04\x96J)\xff!\x88~\xb3b\xbb\xf3\xf7\x82\xc8\
={\xd98^\xb8nA\xccs\xda\x89B\xd6\x0b\xad\
.\xac\x0f\xacz\xf9\xd5\x96\xc6\x8f7\x86\xd8vP\xf6\
\x9e\xeck^\x8dse\xedN_\xe7\xbe\xd7\xa3\xee\xc7\
\xfa\x83\xc1Gv\x05\xbc\xe2\xafANW\xe7\x9e\xf2\x0c\
,.\x0du?Y\xe4\xe3'K\xb2\x5cv\xb8\xe8\x0c\
se\xb8\xca\xd5\x9a\xc7\x9e\x17\xf0\xd0'\xbe4\x96\x16\
K\x18+\x8f\xf7\xd7\x8d<\xbdGN\xca\xda\xd3S\xd4\
\xd9\xee\xa9\xfc\x10?*\xd5\x1e\xda\xe2p\xe0)q\xd7\
\xe5\x9a\xe73s\xc8\xb3W:l\xf7\x94\xbb\xf1\x17\xc6\
\xff\x1c\xf3.\xe7wm\xf7\x95x\xf3\xbbU\x99\x88o\
\xd9\xefJ,)\x0e\xe2\x1c!\x8c\x81\x5cO\xe9\x82\xd0\
\xe3\x8dWw\x187\x1e\xfdB\xc2\x8d1\x15\xa1<\xe2\
n\xb8\x10\xff\x17\x1a\x96\x9e\xd2\xd9Z\xe6L-\xdc\xe3\
\x8c\xc7y\xbe\x06\xff\x01\xd8\x02\xdah\xfa;i$\x00\
\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x07\xe4\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -906,6 +1014,11 @@ qt_resource_name = b"\
\x00d\
\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\
\x00p\x00n\x00g\
\x00\x14\
\x0c\xbb2g\
\x00t\
\x00r\x00a\x00n\x00s\x00f\x00o\x00r\x00m\x00-\x00r\x00o\x00t\x00a\x00t\x00e\x00.\
\x00p\x00n\x00g\
\x00\x0d\
\x03\xd2\xbeg\
\x00e\
@@ -943,25 +1056,27 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x09\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0a\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00\x19Z\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xfa\
\x00\x00\x01\x9f{C\xf1'\
\x00\x00\x00\xe2\x00\x00\x00\x00\x00\x01\x00\x00$\x8c\
\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00+,\
\x00\x00\x01\x9f{0\xc99\
\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\
\x00\x00\x00\x92\x00\x00\x00\x00\x00\x01\x00\x00\x14\x92\
\x00\x00\x01\x9f{C\xf1\x18\
\x00\x00\x00\x84\x00\x00\x00\x00\x00\x01\x00\x00\x15\xda\
\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x1cz\
\x00\x00\x01\x9f{C\xf1.\
\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\
\x00\x00\x01\x9f{0\xc9B\
\x00\x00\x01\x0a\x00\x00\x00\x00\x00\x01\x00\x00+\x96\
\x00\x00\x018\x00\x00\x00\x00\x00\x01\x00\x0026\
\x00\x00\x01\x9f{C\xf1N\
\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\
\x00\x00\x01\x9f{\x8d\xf34\
\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9f{0\xc9+\
\x00\x00\x00\xc2\x00\x00\x00\x00\x00\x01\x00\x00\x1c\xba\
\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00#Z\
\x00\x00\x01\x9f{C\xf1=\
\x00\x00\x01,\x00\x00\x00\x00\x00\x01\x00\x001\x18\
\x00\x00\x01Z\x00\x00\x00\x00\x00\x01\x00\x007\xb8\
\x00\x00\x01\x9f{0\xc9R\
"

View File

@@ -1,17 +1,26 @@
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QDialog
from pathlib import Path
from PySide6.QtCore import QSettings, Signal
from PySide6.QtWidgets import QDialog, QFileDialog
from bedit.library.repository import default_library_paths
from bedit.ui_settings_dialog import Ui_SettingsDialog
class SettingsDialog(QDialog):
"""Edit application preferences defined in the Designer form."""
settingsChanged = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_SettingsDialog()
self.ui.setupUi(self)
self.settings = QSettings()
self.ui.addLibraryFileButton.clicked.connect(self._add_library_file)
self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder)
self.ui.removeLibraryPathButton.clicked.connect(self._remove_library_path)
self.ui.libraryPathsList.itemSelectionChanged.connect(self._update_remove_button)
self._load_settings()
def _load_settings(self) -> None:
@@ -21,10 +30,59 @@ class SettingsDialog(QDialog):
self.ui.autosaveIntervalSpinBox.setValue(
self.settings.value("general/autosaveInterval", 5, type=int)
)
self.ui.libraryPathsList.clear()
self.ui.libraryPathsList.addItems(self.library_paths(self.settings))
self._update_remove_button()
@staticmethod
def library_paths(settings: QSettings | None = None) -> list[str]:
settings = settings or QSettings()
value = settings.value("libraries/paths", default_library_paths())
if isinstance(value, str):
return [value]
return [str(path) for path in value]
def _add_library_file(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Add library",
"",
"BEdit libraries (*.json);;All files (*)",
)
if path:
self._append_unique_path(path)
def _add_library_folder(self) -> None:
path = QFileDialog.getExistingDirectory(self, "Add library folder")
if path:
self._append_unique_path(path)
def _append_unique_path(self, path: str) -> None:
normalized = str(Path(path).expanduser().resolve())
existing = {
self.ui.libraryPathsList.item(row).text()
for row in range(self.ui.libraryPathsList.count())
}
if normalized not in existing:
self.ui.libraryPathsList.addItem(normalized)
def _remove_library_path(self) -> None:
for item in self.ui.libraryPathsList.selectedItems():
self.ui.libraryPathsList.takeItem(self.ui.libraryPathsList.row(item))
def _update_remove_button(self) -> None:
self.ui.removeLibraryPathButton.setEnabled(bool(self.ui.libraryPathsList.selectedItems()))
def accept(self) -> None:
self.settings.setValue("general/autosaveEnabled", self.ui.autosaveGroupBox.isChecked())
self.settings.setValue(
"general/autosaveInterval", self.ui.autosaveIntervalSpinBox.value()
)
paths = [
self.ui.libraryPathsList.item(row).text()
for row in range(self.ui.libraryPathsList.count())
]
self.settings.setValue("libraries/paths", paths)
self.settings.sync()
self.settingsChanged.emit()
super().accept()

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'component_options_dialog.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QComboBox,
QDialog, QDialogButtonBox, QFormLayout, QLabel,
QLineEdit, QSizePolicy, QSpacerItem, QVBoxLayout,
QWidget)
class Ui_ComponentOptionsDialog(object):
def setupUi(self, ComponentOptionsDialog):
if not ComponentOptionsDialog.objectName():
ComponentOptionsDialog.setObjectName(u"ComponentOptionsDialog")
ComponentOptionsDialog.resize(420, 260)
self.dialogLayout = QVBoxLayout(ComponentOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.optionsForm = QFormLayout()
self.optionsForm.setObjectName(u"optionsForm")
self.nameLabel = QLabel(ComponentOptionsDialog)
self.nameLabel.setObjectName(u"nameLabel")
self.optionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel)
self.nameEdit = QLineEdit(ComponentOptionsDialog)
self.nameEdit.setObjectName(u"nameEdit")
self.optionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
self.shapeLabel = QLabel(ComponentOptionsDialog)
self.shapeLabel.setObjectName(u"shapeLabel")
self.optionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.shapeLabel)
self.shapeCombo = QComboBox(ComponentOptionsDialog)
self.shapeCombo.addItem("")
self.shapeCombo.addItem("")
self.shapeCombo.setObjectName(u"shapeCombo")
self.optionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.shapeCombo)
self.iconTextLabel = QLabel(ComponentOptionsDialog)
self.iconTextLabel.setObjectName(u"iconTextLabel")
self.optionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.iconTextLabel)
self.iconTextEdit = QLineEdit(ComponentOptionsDialog)
self.iconTextEdit.setObjectName(u"iconTextEdit")
self.optionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.iconTextEdit)
self.fillLabel = QLabel(ComponentOptionsDialog)
self.fillLabel.setObjectName(u"fillLabel")
self.optionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.fillLabel)
self.fillEdit = QLineEdit(ComponentOptionsDialog)
self.fillEdit.setObjectName(u"fillEdit")
self.optionsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.fillEdit)
self.borderLabel = QLabel(ComponentOptionsDialog)
self.borderLabel.setObjectName(u"borderLabel")
self.optionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.borderLabel)
self.borderEdit = QLineEdit(ComponentOptionsDialog)
self.borderEdit.setObjectName(u"borderEdit")
self.optionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.borderEdit)
self.showSubtreeCheckBox = QCheckBox(ComponentOptionsDialog)
self.showSubtreeCheckBox.setObjectName(u"showSubtreeCheckBox")
self.showSubtreeCheckBox.setChecked(True)
self.optionsForm.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.showSubtreeCheckBox)
self.dialogLayout.addLayout(self.optionsForm)
self.optionsSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.dialogLayout.addItem(self.optionsSpacer)
self.buttonBox = QDialogButtonBox(ComponentOptionsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(ComponentOptionsDialog)
self.buttonBox.accepted.connect(ComponentOptionsDialog.accept)
self.buttonBox.rejected.connect(ComponentOptionsDialog.reject)
QMetaObject.connectSlotsByName(ComponentOptionsDialog)
# setupUi
def retranslateUi(self, ComponentOptionsDialog):
ComponentOptionsDialog.setWindowTitle(QCoreApplication.translate("ComponentOptionsDialog", u"Component Options", None))
self.nameLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Name:", None))
self.shapeLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon shape:", None))
self.shapeCombo.setItemText(0, QCoreApplication.translate("ComponentOptionsDialog", u"rectangle", None))
self.shapeCombo.setItemText(1, QCoreApplication.translate("ComponentOptionsDialog", u"ellipse", None))
self.iconTextLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon text:", None))
self.fillLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Fill color:", None))
self.fillEdit.setPlaceholderText(QCoreApplication.translate("ComponentOptionsDialog", u"#dbeafe", None))
self.borderLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Border color:", None))
self.borderEdit.setPlaceholderText(QCoreApplication.translate("ComponentOptionsDialog", u"#303030", None))
self.showSubtreeCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show contained components in the Libraries tree", None))
# retranslateUi

View File

@@ -16,10 +16,13 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QHBoxLayout, QHeaderView,
QMainWindow, QMenu, QMenuBar, QSizePolicy,
QSplitter, QToolBar, QTreeView, QVBoxLayout,
QWidget)
from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout,
QHeaderView, QLabel, QMainWindow, QMenu,
QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy,
QSpacerItem, QSplitter, QStackedWidget, QToolBar,
QToolButton, QTreeView, QVBoxLayout, QWidget)
from bedit.workspace.view import GraphWorkspaceView
from . import resources_rc
class Ui_MainWindow(object):
@@ -32,50 +35,59 @@ class Ui_MainWindow(object):
icon = QIcon()
icon.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon)
self.actionRotateClockwise = QAction(MainWindow)
self.actionRotateClockwise.setObjectName(u"actionRotateClockwise")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRotateClockwise.setIcon(icon1)
self.actionOpen = QAction(MainWindow)
self.actionOpen.setObjectName(u"actionOpen")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon1)
icon2 = QIcon()
icon2.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon2)
self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon2)
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon3)
self.actionSaveAs = QAction(MainWindow)
self.actionSaveAs.setObjectName(u"actionSaveAs")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon3)
icon4 = QIcon()
icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon4)
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")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon4)
icon5 = QIcon()
icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon5)
self.actionRedo = QAction(MainWindow)
self.actionRedo.setObjectName(u"actionRedo")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon5)
icon6 = QIcon()
icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon6)
self.actionCut = QAction(MainWindow)
self.actionCut.setObjectName(u"actionCut")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon6)
icon7 = QIcon()
icon7.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon7)
self.actionCopy = QAction(MainWindow)
self.actionCopy.setObjectName(u"actionCopy")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon7)
icon8 = QIcon()
icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon8)
self.actionPaste = QAction(MainWindow)
self.actionPaste.setObjectName(u"actionPaste")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon8)
icon9 = QIcon()
icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon9)
self.actionSelectAll = QAction(MainWindow)
self.actionSelectAll.setObjectName(u"actionSelectAll")
self.actionDelete = QAction(MainWindow)
self.actionDelete.setObjectName(u"actionDelete")
self.actionAbout = QAction(MainWindow)
self.actionAbout.setObjectName(u"actionAbout")
self.actionSettings = QAction(MainWindow)
@@ -118,6 +130,24 @@ class Ui_MainWindow(object):
self.panel_libraries.setWidget(self.dockWidgetContents)
self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_libraries)
self.panel_document = QDockWidget(self.leftDockHost)
self.panel_document.setObjectName(u"panel_document")
self.panel_document.setMinimumSize(QSize(220, 91))
self.documentDockContents = QWidget()
self.documentDockContents.setObjectName(u"documentDockContents")
self.documentPanelLayout = QVBoxLayout(self.documentDockContents)
self.documentPanelLayout.setSpacing(0)
self.documentPanelLayout.setObjectName(u"documentPanelLayout")
self.documentPanelLayout.setContentsMargins(0, 0, 0, 0)
self.documentTreeView = QTreeView(self.documentDockContents)
self.documentTreeView.setObjectName(u"documentTreeView")
self.documentTreeView.setAlternatingRowColors(True)
self.documentTreeView.setUniformRowHeights(True)
self.documentPanelLayout.addWidget(self.documentTreeView)
self.panel_document.setWidget(self.documentDockContents)
self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_document)
self.workspaceSplitter.addWidget(self.leftDockHost)
self.workspace = QWidget(self.workspaceSplitter)
self.workspace.setObjectName(u"workspace")
@@ -126,9 +156,107 @@ class Ui_MainWindow(object):
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.workspace.sizePolicy().hasHeightForWidth())
self.workspace.setSizePolicy(sizePolicy1)
self.workspace.setStyleSheet(u"QWidget#workspace {\n"
" background-color: rgb(198, 198, 198);\n"
"}")
self.workspaceEditorLayout = QVBoxLayout(self.workspace)
self.workspaceEditorLayout.setSpacing(0)
self.workspaceEditorLayout.setObjectName(u"workspaceEditorLayout")
self.workspaceEditorLayout.setContentsMargins(0, 0, 0, 0)
self.workspaceHeader = QFrame(self.workspace)
self.workspaceHeader.setObjectName(u"workspaceHeader")
self.workspaceHeader.setMinimumSize(QSize(0, 34))
self.workspaceHeader.setMaximumSize(QSize(16777215, 34))
self.workspaceHeader.setFrameShape(QFrame.Shape.StyledPanel)
self.workspaceHeaderLayout = QHBoxLayout(self.workspaceHeader)
self.workspaceHeaderLayout.setObjectName(u"workspaceHeaderLayout")
self.workspaceHeaderLayout.setContentsMargins(6, 2, 6, 2)
self.navigateUpButton = QToolButton(self.workspaceHeader)
self.navigateUpButton.setObjectName(u"navigateUpButton")
self.workspaceHeaderLayout.addWidget(self.navigateUpButton)
self.graphBreadcrumbLabel = QLabel(self.workspaceHeader)
self.graphBreadcrumbLabel.setObjectName(u"graphBreadcrumbLabel")
self.workspaceHeaderLayout.addWidget(self.graphBreadcrumbLabel)
self.workspaceModeLabel = QLabel(self.workspaceHeader)
self.workspaceModeLabel.setObjectName(u"workspaceModeLabel")
self.workspaceHeaderLayout.addWidget(self.workspaceModeLabel)
self.workspaceHeaderSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.workspaceHeaderLayout.addItem(self.workspaceHeaderSpacer)
self.applyJsonButton = QPushButton(self.workspaceHeader)
self.applyJsonButton.setObjectName(u"applyJsonButton")
self.applyJsonButton.setVisible(False)
self.workspaceHeaderLayout.addWidget(self.applyJsonButton)
self.pointerToolButton = QToolButton(self.workspaceHeader)
self.pointerToolButton.setObjectName(u"pointerToolButton")
self.pointerToolButton.setCheckable(True)
self.pointerToolButton.setChecked(True)
self.pointerToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.pointerToolButton)
self.inputToolButton = QToolButton(self.workspaceHeader)
self.inputToolButton.setObjectName(u"inputToolButton")
self.inputToolButton.setCheckable(True)
self.inputToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.inputToolButton)
self.outputToolButton = QToolButton(self.workspaceHeader)
self.outputToolButton.setObjectName(u"outputToolButton")
self.outputToolButton.setCheckable(True)
self.outputToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.outputToolButton)
self.workspaceEditorLayout.addWidget(self.workspaceHeader)
self.workspaceStack = QStackedWidget(self.workspace)
self.workspaceStack.setObjectName(u"workspaceStack")
self.graphPage = QWidget()
self.graphPage.setObjectName(u"graphPage")
self.graphPageLayout = QVBoxLayout(self.graphPage)
self.graphPageLayout.setObjectName(u"graphPageLayout")
self.graphPageLayout.setContentsMargins(0, 0, 0, 0)
self.graphView = GraphWorkspaceView(self.graphPage)
self.graphView.setObjectName(u"graphView")
self.graphPageLayout.addWidget(self.graphView)
self.workspaceStack.addWidget(self.graphPage)
self.jsonPage = QWidget()
self.jsonPage.setObjectName(u"jsonPage")
self.jsonPageLayout = QVBoxLayout(self.jsonPage)
self.jsonPageLayout.setObjectName(u"jsonPageLayout")
self.jsonPageLayout.setContentsMargins(0, 0, 0, 0)
self.jsonEditor = QPlainTextEdit(self.jsonPage)
self.jsonEditor.setObjectName(u"jsonEditor")
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.jsonPageLayout.addWidget(self.jsonEditor)
self.workspaceStack.addWidget(self.jsonPage)
self.emptyPage = QWidget()
self.emptyPage.setObjectName(u"emptyPage")
self.emptyPageLayout = QVBoxLayout(self.emptyPage)
self.emptyPageLayout.setObjectName(u"emptyPageLayout")
self.emptyWorkspaceLabel = QLabel(self.emptyPage)
self.emptyWorkspaceLabel.setObjectName(u"emptyWorkspaceLabel")
self.emptyWorkspaceLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.emptyPageLayout.addWidget(self.emptyWorkspaceLabel)
self.workspaceStack.addWidget(self.emptyPage)
self.workspaceEditorLayout.addWidget(self.workspaceStack)
self.workspaceSplitter.addWidget(self.workspace)
self.workspaceLayout.addWidget(self.workspaceSplitter)
@@ -164,6 +292,9 @@ 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.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction())
@@ -175,6 +306,7 @@ class Ui_MainWindow(object):
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionClose)
self.menuFile.addAction(self.actionExit)
self.menuEdit.addAction(self.actionUndo)
self.menuEdit.addAction(self.actionRedo)
@@ -182,6 +314,8 @@ class Ui_MainWindow(object):
self.menuEdit.addAction(self.actionCopy)
self.menuEdit.addAction(self.actionCut)
self.menuEdit.addAction(self.actionPaste)
self.menuEdit.addAction(self.actionDelete)
self.menuEdit.addAction(self.actionSelectAll)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionSettings)
self.menuView.addAction(self.menuPanels.menuAction())
@@ -197,9 +331,13 @@ 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.retranslateUi(MainWindow)
self.workspaceStack.setCurrentIndex(0)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
@@ -211,6 +349,13 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionNew.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None))
#endif // QT_CONFIG(shortcut)
self.actionRotateClockwise.setText(QCoreApplication.translate("MainWindow", u"Rotate Clockwise", None))
#if QT_CONFIG(tooltip)
self.actionRotateClockwise.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise by 90 degrees", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionRotateClockwise.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+R", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None))
#if QT_CONFIG(statustip)
@@ -233,6 +378,10 @@ class Ui_MainWindow(object):
self.actionExit.setText(QCoreApplication.translate("MainWindow", u"E&xit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
self.actionClose.setText(QCoreApplication.translate("MainWindow", u"&Close Document", None))
#if QT_CONFIG(shortcut)
self.actionClose.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+W", None))
#endif // QT_CONFIG(shortcut)
self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"&Undo", None))
#if QT_CONFIG(shortcut)
@@ -257,6 +406,10 @@ class Ui_MainWindow(object):
self.actionSelectAll.setText(QCoreApplication.translate("MainWindow", u"Select &All", None))
#if QT_CONFIG(shortcut)
self.actionSelectAll.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+A", None))
#endif // QT_CONFIG(shortcut)
self.actionDelete.setText(QCoreApplication.translate("MainWindow", u"&Delete", None))
#if QT_CONFIG(shortcut)
self.actionDelete.setShortcut(QCoreApplication.translate("MainWindow", u"Del", None))
#endif // QT_CONFIG(shortcut)
self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"&About BEdit", None))
self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"&Settings\u2026", None))
@@ -265,6 +418,25 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip)
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.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.inputToolButton.setText(QCoreApplication.translate("MainWindow", u"Input", None))
#if QT_CONFIG(tooltip)
self.inputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface input", None))
#endif // QT_CONFIG(tooltip)
self.outputToolButton.setText(QCoreApplication.translate("MainWindow", u"Output", None))
#if QT_CONFIG(tooltip)
self.outputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface output", None))
#endif // QT_CONFIG(tooltip)
self.jsonEditor.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Component JSON", None))
self.emptyWorkspaceLabel.setText(QCoreApplication.translate("MainWindow", u"No document open", None))
self.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None))
self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None))
self.menuView.setTitle(QCoreApplication.translate("MainWindow", u"&View", None))
@@ -273,5 +445,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))
# retranslateUi

View File

@@ -16,8 +16,10 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox,
QFormLayout, QGroupBox, QSizePolicy, QSpacerItem,
QSpinBox, QTabWidget, QVBoxLayout, QWidget)
QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSpacerItem, QSpinBox, QTabWidget, QVBoxLayout,
QWidget)
class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog):
@@ -55,6 +57,46 @@ class Ui_SettingsDialog(object):
self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "")
self.librariesTab = QWidget()
self.librariesTab.setObjectName(u"librariesTab")
self.librariesTabLayout = QVBoxLayout(self.librariesTab)
self.librariesTabLayout.setObjectName(u"librariesTabLayout")
self.libraryPathsLabel = QLabel(self.librariesTab)
self.libraryPathsLabel.setObjectName(u"libraryPathsLabel")
self.libraryPathsLabel.setWordWrap(True)
self.librariesTabLayout.addWidget(self.libraryPathsLabel)
self.libraryPathsList = QListWidget(self.librariesTab)
self.libraryPathsList.setObjectName(u"libraryPathsList")
self.librariesTabLayout.addWidget(self.libraryPathsList)
self.libraryPathButtonsLayout = QHBoxLayout()
self.libraryPathButtonsLayout.setObjectName(u"libraryPathButtonsLayout")
self.addLibraryFileButton = QPushButton(self.librariesTab)
self.addLibraryFileButton.setObjectName(u"addLibraryFileButton")
self.libraryPathButtonsLayout.addWidget(self.addLibraryFileButton)
self.addLibraryFolderButton = QPushButton(self.librariesTab)
self.addLibraryFolderButton.setObjectName(u"addLibraryFolderButton")
self.libraryPathButtonsLayout.addWidget(self.addLibraryFolderButton)
self.removeLibraryPathButton = QPushButton(self.librariesTab)
self.removeLibraryPathButton.setObjectName(u"removeLibraryPathButton")
self.libraryPathButtonsLayout.addWidget(self.removeLibraryPathButton)
self.libraryButtonsSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.libraryPathButtonsLayout.addItem(self.libraryButtonsSpacer)
self.librariesTabLayout.addLayout(self.libraryPathButtonsLayout)
self.settingsTabs.addTab(self.librariesTab, "")
self.dialogLayout.addWidget(self.settingsTabs)
@@ -81,5 +123,10 @@ class Ui_SettingsDialog(object):
self.autosaveGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Automatic saving", None))
self.autosaveIntervalSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" minutes", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None))
self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON files from these files or folders at startup:", None))
self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None))
self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None))
self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.librariesTab), QCoreApplication.translate("SettingsDialog", u"Libraries", None))
# retranslateUi

View File

@@ -0,0 +1,4 @@
from bedit.workspace.view import GraphWorkspaceView
__all__ = ["GraphWorkspaceView"]

View File

@@ -0,0 +1,547 @@
import json
from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor,
QDragEnterEvent,
QDropEvent,
QMouseEvent,
QPainter,
QPainterPath,
QPen,
QTransform,
)
from PySide6.QtWidgets import (
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QGraphicsView,
QApplication,
QMenu,
QStyleOptionGraphicsItem,
QWidget,
)
from bedit.document.controller import DocumentController
from bedit.document.model import Component, Connection, Endpoint, Port
from bedit.library.tree_model import COMPONENT_MIME_TYPE
SELECTION_MIME_TYPE = "application/x-bedit-selection"
class ConnectionPortItem(QGraphicsEllipseItem):
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
super().__init__(-6, -6, 12, 12, parent)
self.endpoint = endpoint
self.role = role
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#303030"), 1.5))
self.setZValue(2)
self.setToolTip(label)
class ComponentGraphicsItem(QGraphicsObject):
WIDTH = 120.0
HEIGHT = 72.0
def __init__(self, component: Component, controller: DocumentController) -> None:
super().__init__()
self.component_id = component.id
self.component = component
self.controller = controller
self.drag_start = QPointF()
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
self.setTransformOriginPoint(self.WIDTH / 2, self.HEIGHT / 2)
self.setRotation(component.rotation)
def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]:
result = {}
spacing = self.HEIGHT / (len(ports) + 1)
for index, port in enumerate(ports, start=1):
endpoint = Endpoint(block=self.component_id, port=port.id)
item = ConnectionPortItem(endpoint, role, port.name, self)
item.setPos(x, spacing * index)
result[port.id] = item
return result
def boundingRect(self) -> QRectF: # noqa: N802
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
def paint(
self,
painter: QPainter,
option: QStyleOptionGraphicsItem,
widget: QWidget | None = None,
) -> None:
del option, widget
icon = self.component.icon
fill = QColor("#dbeafe") if self.isSelected() else QColor(icon.fill)
painter.setBrush(fill)
painter.setPen(QPen(QColor(icon.border), 1.5))
if icon.shape == "ellipse":
painter.drawEllipse(self.boundingRect())
else:
painter.drawRoundedRect(self.boundingRect(), 5, 5)
painter.setPen(QColor("#202020"))
painter.drawText(
self.boundingRect(),
Qt.AlignmentFlag.AlignCenter,
icon.text or self.component.name,
)
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.controller.activate_component(self.component_id)
event.accept()
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
scene = self.scene()
if scene is not None:
scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options_action = menu.addAction("Component Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.componentOptionsRequested.emit(self.component_id)
event.accept()
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)
self.controller.move_component(self.component_id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_block(self.component_id)
return super().itemChange(change, value)
class InterfaceTerminalItem(QGraphicsObject):
WIDTH = 110.0
HEIGHT = 36.0
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
super().__init__()
self.port = port
self.direction = direction
self.controller = controller
self.drag_start = QPointF()
role = "source" if direction == "input" else "target"
self.connection_port = ConnectionPortItem(
Endpoint(interface=port.id), role, port.name, self
)
connection_x = self.WIDTH if direction == "input" else 0.0
self.connection_port.setPos(connection_x, self.HEIGHT / 2)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setToolTip(f"Component {direction}: {port.name}")
def boundingRect(self) -> QRectF: # noqa: N802
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
def paint(
self,
painter: QPainter,
option: QStyleOptionGraphicsItem,
widget: QWidget | None = None,
) -> None:
del option, widget
painter.setBrush(QColor("#e5e7eb"))
painter.setPen(QPen(QColor("#4b5563"), 1.5))
painter.drawRoundedRect(self.boundingRect(), 4, 4)
painter.setPen(QColor("#202020"))
marker = "IN" if self.direction == "input" else "OUT"
painter.drawText(
self.boundingRect().adjusted(8, 0, -8, 0),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
f"{marker} {self.port.name}",
)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options_action = menu.addAction(f"{self.direction.title()} Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.portOptionsRequested.emit(self.port.id, self.direction)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.controller.move_interface_port(self.port.id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_interface(self.port.id)
return super().itemChange(change, value)
class ConnectionGraphicsItem(QGraphicsPathItem):
def __init__(self, connection_id: str, name: str = "") -> None:
super().__init__()
self.connection_id = connection_id
self.name = name
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self._update_pen()
self.setZValue(-1)
self.setToolTip(name or "Connection")
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options_action = menu.addAction("Connection Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.connectionOptionsRequested.emit(self.connection_id)
event.accept()
def itemChange(self, change, value): # noqa: N802
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self._update_pen()
return result
def _update_pen(self) -> None:
self.setPen(
QPen(
QColor("#f59e0b") if self.isSelected() else QColor("#285f9e"),
4.0 if self.isSelected() else 2.5,
)
)
class GraphScene(QGraphicsScene):
componentOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str)
def __init__(self, controller: DocumentController, parent=None) -> None:
super().__init__(parent)
self.controller = controller
self.component_items: dict[str, ComponentGraphicsItem] = {}
self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.pending_source: ConnectionPortItem | None = None
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)
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.component_items.clear()
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.pending_source = None
owner = self.controller.active_component
if owner is None or owner.implementation_kind != "graph":
return
for port in owner.inputs:
item = InterfaceTerminalItem(port, "input", self.controller)
self.addItem(item)
item.setPos(port.x, port.y)
self.input_items[port.id] = item
for port in owner.outputs:
item = InterfaceTerminalItem(port, "output", self.controller)
self.addItem(item)
item.setPos(port.x, port.y)
self.output_items[port.id] = item
for component in owner.graph.blocks.values():
item = ComponentGraphicsItem(component, self.controller)
self.addItem(item)
item.setPos(component.x, component.y)
self.component_items[component.id] = item
for connection in owner.graph.connections.values():
item = ConnectionGraphicsItem(connection.id, connection.name)
self.addItem(item)
self.connection_items[connection.id] = item
self.update_connection(connection.id)
def set_component_position(self, component_id: str, position: QPointF) -> None:
item = self.component_items.get(component_id)
if item is not None and item.pos() != position:
item.setPos(position)
def set_component_rotation(self, component_id: str, rotation: float) -> None:
item = self.component_items.get(component_id)
if item is not None:
item.setRotation(rotation)
self.update_connections_for_block(component_id)
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):
self.update_connection(connection.id)
def update_connections_for_interface(self, port_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if port_id in (connection.source.interface, connection.target.interface):
self.update_connection(connection.id)
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
painter.fillRect(rect, QColor("#e4e4e4"))
if self.controller.document is None or self.controller.active_component is None:
return
spacing = 32
left = int(rect.left()) - (int(rect.left()) % spacing)
top = int(rect.top()) - (int(rect.top()) % spacing)
painter.setPen(QPen(QColor("#b8b8b8"), 1))
for x in range(left, int(rect.right()) + spacing, spacing):
for y in range(top, int(rect.bottom()) + spacing, spacing):
painter.drawPoint(x, y)
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
if endpoint.interface is not None:
terminals = self.input_items if role == "source" else self.output_items
terminal = terminals.get(endpoint.interface)
return terminal.connection_port if terminal else None
component = self.component_items.get(endpoint.block or "")
if component is None:
return None
ports = component.output_ports if role == "source" else component.input_ports
return ports.get(endpoint.port or "")
def update_connection(self, connection_id: str) -> None:
connection = self.controller.active_graph.connections.get(connection_id)
graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None:
return
source = self._endpoint_item(connection.source, "source")
target = self._endpoint_item(connection.target, "target")
if source is None or target is None:
return
start, end = source.scenePos(), target.scenePos()
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
path = QPainterPath(start)
path.cubicTo(start + QPointF(distance, 0), end - QPointF(distance, 0), end)
graphics.setPath(path)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
item = self.itemAt(event.scenePos(), QTransform())
if isinstance(item, ConnectionPortItem):
if item.role == "source":
self._clear_pending_source()
self.pending_source = item
item.setBrush(QColor("#f5b642"))
elif self.pending_source is not None:
if self.pending_source.endpoint != item.endpoint:
self.controller.connect(self.pending_source.endpoint, item.endpoint)
self._clear_pending_source()
event.accept()
return
self._clear_pending_source()
super().mousePressEvent(event)
def _clear_pending_source(self) -> None:
if self.pending_source is not None:
self.pending_source.setBrush(QColor("#ffffff"))
self.pending_source = None
class GraphWorkspaceView(QGraphicsView):
toolUsed = Signal()
componentOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str)
selectionAvailabilityChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.controller: DocumentController | None = None
self.tool_mode = "pointer"
self.paste_count = 0
self.setAcceptDrops(True)
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.setBackgroundBrush(QColor("#9a9a9a"))
def set_model(self, controller: DocumentController) -> None:
self.controller = controller
scene = GraphScene(controller, self)
scene.componentOptionsRequested.connect(self.componentOptionsRequested)
scene.portOptionsRequested.connect(self.portOptionsRequested)
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
scene.selectionChanged.connect(
lambda: self.selectionAvailabilityChanged.emit(bool(scene.selectedItems()))
)
self.setScene(scene)
def select_all(self) -> None:
scene = self.scene()
if scene is None:
return
for item in scene.items():
if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsSelectable:
item.setSelected(True)
def delete_selected(self) -> None:
if self.controller is None or not isinstance(self.scene(), GraphScene):
return
blocks: set[str] = set()
connections: set[str] = set()
inputs: set[str] = set()
outputs: set[str] = set()
for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem):
blocks.add(item.component_id)
elif isinstance(item, ConnectionGraphicsItem):
connections.add(item.connection_id)
elif isinstance(item, InterfaceTerminalItem):
(inputs if item.direction == "input" else outputs).add(item.port.id)
self.controller.delete_selection(blocks, connections, inputs, outputs)
def has_selected_components(self) -> bool:
scene = self.scene()
return bool(
scene
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
)
def rotate_selected(self) -> None:
if self.controller is None or self.scene() is None:
return
component_ids = {
item.component_id
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
}
self.controller.rotate_components(component_ids)
def copy_selection(self) -> bool:
if self.controller is None or not isinstance(self.scene(), GraphScene):
return False
selected_ids = {
item.component_id
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
}
if not selected_ids:
return False
graph = self.controller.active_graph
components = [graph.blocks[component_id].to_dict() for component_id in selected_ids]
connections = [
connection.to_dict()
for connection in graph.connections.values()
if connection.source.block in selected_ids and connection.target.block in selected_ids
]
mime_data = QMimeData()
mime_data.setData(
SELECTION_MIME_TYPE,
json.dumps({"components": components, "connections": connections}).encode("utf-8"),
)
QApplication.clipboard().setMimeData(mime_data)
self.paste_count = 0
return True
def cut_selection(self) -> None:
if self.copy_selection():
self.delete_selected()
def paste_selection(self) -> None:
if self.controller is None:
return
mime_data = QApplication.clipboard().mimeData()
if not mime_data.hasFormat(SELECTION_MIME_TYPE):
return
try:
payload = json.loads(bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8"))
components = [Component.from_dict(item) for item in payload.get("components", [])]
connections = [Connection.from_dict(item) for item in payload.get("connections", [])]
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
return
self.paste_count += 1
new_ids = self.controller.paste_selection(
components,
connections,
QPointF(32 * self.paste_count, 32 * self.paste_count),
)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.clearSelection()
for component_id in new_ids:
item = scene.component_items.get(component_id)
if item is not None:
item.setSelected(True)
def set_tool_mode(self, mode: str) -> None:
self.tool_mode = mode
self.setDragMode(
QGraphicsView.DragMode.RubberBandDrag
if mode == "pointer"
else QGraphicsView.DragMode.NoDrag
)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if (
self.controller is not None
and self.tool_mode in {"input", "output"}
and event.button() == Qt.MouseButton.LeftButton
):
self.controller.add_interface_port(self.tool_mode, self.mapToScene(event.position().toPoint()))
self.toolUsed.emit()
event.accept()
return
super().mousePressEvent(event)
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
if (
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
and self.controller is not None
and self.controller.active_component is not None
and self.controller.active_component.implementation_kind == "graph"
):
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event) -> None: # noqa: N802
if (
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
and self.controller is not None
and self.controller.active_component is not None
and self.controller.active_component.implementation_kind == "graph"
):
event.acceptProposedAction()
return
super().dragMoveEvent(event)
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
if self.controller is None or not event.mimeData().hasFormat(COMPONENT_MIME_TYPE):
super().dropEvent(event)
return
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
source = Component.from_dict(data)
self.controller.add_component_copy(source, self.mapToScene(event.position().toPoint()))
event.acceptProposedAction()