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

@@ -98,13 +98,28 @@
"panel": "shared" "panel": "shared"
} }
}, },
{
"label": "Qt: Compile Component Options UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": [
"--from-imports",
"${workspaceFolder}/ui/component_options_dialog.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_component_options_dialog.py"
],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": [],
"presentation": {"reveal": "silent", "panel": "shared"}
},
{ {
"label": "Qt: Build Designer Files", "label": "Qt: Build Designer Files",
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"Qt: Compile Resources to Python", "Qt: Compile Resources to Python",
"Qt: Compile UI to Python", "Qt: Compile UI to Python",
"Qt: Compile Settings UI to Python" "Qt: Compile Settings UI to Python",
"Qt: Compile Component Options UI to Python"
], ],
"problemMatcher": [], "problemMatcher": [],
"group": { "group": {

View File

@@ -66,7 +66,7 @@ pyside6-uic ui/main_window.ui -o src/bedit/ui_main_window.py
Do not hand-edit the generated Python file; change the `.ui` file and regenerate Do not hand-edit the generated Python file; change the `.ui` file and regenerate
it. Add behavior and signal connections in `main_window.py`. Widget names from it. Add behavior and signal connections in `main_window.py`. Widget names from
Designer are available there through `self.ui`, such as `self.ui.editor`. Designer are available there through `self.ui`, such as `self.ui.graphView`.
In VS Code, the same commands are available through **Terminal → Run Task**: In VS Code, the same commands are available through **Terminal → Run Task**:
@@ -87,6 +87,81 @@ In VS Code, the same commands are available through **Terminal → Run Task**:
6. Add icons through a Qt resource file (`.qrc`) so packaging is reliable. 6. Add icons through a Qt resource file (`.qrc`) so packaging is reliable.
7. Test on Windows regularly; fonts, scaling, and native dialogs vary by platform. 7. Test on Windows regularly; fonts, scaling, and native dialogs vary by platform.
## Graph and library prototype
Documents and libraries use the same recursive format: a library is simply a
BEdit document used as a copy source. The built-in example defines A, B, and C.
- A document can own multiple independent top-level graph or text components.
Right-click **Current Document** to create one, and double-click a current
component in the tree to activate it.
- Drag a component from Libraries onto the workspace. Placement recursively
copies it with new IDs, leaving no link to the source.
- Drag components to move them; movement participates in undo and redo.
- Components, interface terminals, and connections are selectable. Use a rubber
band or Ctrl-click for multiple selection, Delete to remove items, and the
standard Cut/Copy/Paste shortcuts to duplicate selected component groups.
- Click an output port and then an input port to create a connection.
- Double-click a graph component to open its owned subgraph; use **Up** to return.
- Graph components show **Pointer**, **Input**, and **Output** tools. Select an
interface tool and click the canvas to add a visible internal terminal and a
corresponding external block port. Interface terminals can be moved afterward.
- Right-click a component on the canvas or in Current Document to edit its name,
icon shape, icon text, fill color, and border color. The same dialog can hide
that component's contained subtree from the Libraries tree.
- Double-click a text component to edit its input list, output list, and
`implementation.source` JSON.
- Right-click any graph component under Current Document to add nested graph or
text blocks. Any current-document component can also be deleted there.
- The active document hierarchy has its own Document panel; the Libraries panel
contains only configured external libraries.
- Select one or more blocks and press `Ctrl+R`, or use the Transform toolbar, to
rotate them clockwise by 90 degrees. Rotation is saved and supports undo/redo.
**Apply JSON** updates that source and participates in undo/redo.
- File → Save writes the complete recursive document to JSON.
- File → Close Document removes the active document and returns to an empty
workspace. An open graph uses a light gray, 32-unit dotted canvas.
- Edit → Settings → Libraries accepts document files or folders of JSON files.
Every component owns its ports, declarative icon, properties, and child graph:
```json
{
"format": "bedit-document",
"version": 1,
"roots": [{
"id": "my-component",
"name": "My Component",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input"}],
"outputs": [{"id": "out", "name": "Output"}]
},
"icon": {
"shape": "rectangle",
"fill": "#dbeafe",
"border": "#245c9c",
"text": "Component"
},
"properties": {},
"implementation": {
"kind": "text",
"source": {
"equations": ["out = gain * in"],
"parameters": {"gain": 1.0}
}
}
}]
}
```
Graph components use `"implementation": {"kind": "graph", "graph": ...}`;
text components use `"implementation": {"kind": "text", "source": ...}` and
never own a graph. Supported icon shapes are currently `rectangle` and
`ellipse`. The recursive model is under `src/bedit/document/`, library loading
and the live Current Document tree are under `src/bedit/library/`, and graphics
are isolated under `src/bedit/workspace/`.
## Optional tools ## Optional tools
You do not need another GUI framework. Useful additions are: You do not need another GUI framework. Useful additions are:

View File

@@ -24,7 +24,9 @@ bedit = "bedit.app:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
bedit = ["data/libraries/*.json"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py310" target-version = "py310"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -9,5 +9,6 @@
<file>icons/document-save-as.png</file> <file>icons/document-save-as.png</file>
<file>icons/document-open.png</file> <file>icons/document-open.png</file>
<file>icons/document-new.png</file> <file>icons/document-new.png</file>
<file>icons/transform-rotate.png</file>
</qresource> </qresource>
</RCC> </RCC>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import sys import sys
from PySide6.QtCore import QCoreApplication, Qt 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 PySide6.QtWidgets import QApplication, QStyleFactory
from bedit.main_window import MainWindow 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.QtCore import QSettings, Qt, Slot
from PySide6.QtGui import QCloseEvent 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.settings_dialog import SettingsDialog
from bedit.ui_main_window import Ui_MainWindow from bedit.ui_main_window import Ui_MainWindow
class MainWindow(QMainWindow): 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: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -15,44 +29,447 @@ class MainWindow(QMainWindow):
self.ui.setupUi(self) self.ui.setupUi(self)
self.settings = QSettings() 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._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._restore_window_geometry()
self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget) self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget)
self.ui.leftDockHost.show() self.ui.leftDockHost.show()
self.ui.panel_libraries.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.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: def _populate_view_menu(self) -> None:
"""Add checked show/hide actions for panels and toolbars.""" for panel in (self.ui.panel_document, self.ui.panel_libraries):
panels = (self.ui.panel_libraries,)
for panel in panels:
self.ui.menuPanels.addAction(panel.toggleViewAction()) self.ui.menuPanels.addAction(panel.toggleViewAction())
for toolbar in (
toolbars = (self.ui.fileToolbar, self.ui.editToolbar) self.ui.fileToolbar,
for toolbar in toolbars: self.ui.editToolbar,
self.ui.transformToolbar,
):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction()) 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: def _restore_window_geometry(self) -> None:
geometry = self.settings.value("window/geometry") geometry = self.settings.value("window/geometry")
if geometry is not None: if geometry is not None:
self.restoreGeometry(geometry) 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() @Slot()
def show_settings(self) -> None: 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() @Slot()
def show_about(self) -> None: def show_about(self) -> None:
QMessageBox.about( QMessageBox.about(
self, self,
"About BEdit", "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) 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()) self.settings.setValue("window/geometry", self.saveGeometry())
event.accept() 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\ \xe2\xbf\x0f\x0f\x0f\xa7)sR\xf6\xffA\x01\x8e9D\
R\xff\x03\x8c\xc5\xeaCX+lK\x00\x00\x00\x00I\ R\xff\x03\x8c\xc5\xeaCX+lK\x00\x00\x00\x00I\
END\xaeB`\x82\ 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\ \x00\x00\x07\xe4\
\x89\ \x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -906,6 +1014,11 @@ qt_resource_name = b"\
\x00d\ \x00d\
\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\ \x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\
\x00p\x00n\x00g\ \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\ \x00\x0d\
\x03\xd2\xbeg\ \x03\xd2\xbeg\
\x00e\ \x00e\
@@ -943,25 +1056,27 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x00\x00\x00\ \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\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\ \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\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\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\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\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\x01\x9f{C\xf1.\
\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\ \x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\
\x00\x00\x01\x9f{0\xc9B\ \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\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\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9f{0\xc9+\ \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\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\ \x00\x00\x01\x9f{0\xc9R\
" "

View File

@@ -1,17 +1,26 @@
from PySide6.QtCore import QSettings from pathlib import Path
from PySide6.QtWidgets import QDialog
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 from bedit.ui_settings_dialog import Ui_SettingsDialog
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
"""Edit application preferences defined in the Designer form.""" """Edit application preferences defined in the Designer form."""
settingsChanged = Signal()
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self.ui = Ui_SettingsDialog() self.ui = Ui_SettingsDialog()
self.ui.setupUi(self) self.ui.setupUi(self)
self.settings = QSettings() 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() self._load_settings()
def _load_settings(self) -> None: def _load_settings(self) -> None:
@@ -21,10 +30,59 @@ class SettingsDialog(QDialog):
self.ui.autosaveIntervalSpinBox.setValue( self.ui.autosaveIntervalSpinBox.setValue(
self.settings.value("general/autosaveInterval", 5, type=int) 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: def accept(self) -> None:
self.settings.setValue("general/autosaveEnabled", self.ui.autosaveGroupBox.isChecked()) self.settings.setValue("general/autosaveEnabled", self.ui.autosaveGroupBox.isChecked())
self.settings.setValue( self.settings.setValue(
"general/autosaveInterval", self.ui.autosaveIntervalSpinBox.value() "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() 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, QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient, QPainter, QPalette, QPixmap, QRadialGradient,
QTransform) QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QHBoxLayout, QHeaderView, from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout,
QMainWindow, QMenu, QMenuBar, QSizePolicy, QHeaderView, QLabel, QMainWindow, QMenu,
QSplitter, QToolBar, QTreeView, QVBoxLayout, QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy,
QWidget) QSpacerItem, QSplitter, QStackedWidget, QToolBar,
QToolButton, QTreeView, QVBoxLayout, QWidget)
from bedit.workspace.view import GraphWorkspaceView
from . import resources_rc from . import resources_rc
class Ui_MainWindow(object): class Ui_MainWindow(object):
@@ -32,50 +35,59 @@ class Ui_MainWindow(object):
icon = QIcon() icon = QIcon()
icon.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon) 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 = QAction(MainWindow)
self.actionOpen.setObjectName(u"actionOpen") self.actionOpen.setObjectName(u"actionOpen")
icon1 = QIcon() icon2 = QIcon()
icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon2.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon1) self.actionOpen.setIcon(icon2)
self.actionSave = QAction(MainWindow) self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave") self.actionSave.setObjectName(u"actionSave")
icon2 = QIcon() icon3 = QIcon()
icon2.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon2) self.actionSave.setIcon(icon3)
self.actionSaveAs = QAction(MainWindow) self.actionSaveAs = QAction(MainWindow)
self.actionSaveAs.setObjectName(u"actionSaveAs") self.actionSaveAs.setObjectName(u"actionSaveAs")
icon3 = QIcon() icon4 = QIcon()
icon3.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon3) self.actionSaveAs.setIcon(icon4)
self.actionExit = QAction(MainWindow) self.actionExit = QAction(MainWindow)
self.actionExit.setObjectName(u"actionExit") self.actionExit.setObjectName(u"actionExit")
self.actionClose = QAction(MainWindow)
self.actionClose.setObjectName(u"actionClose")
self.actionUndo = QAction(MainWindow) self.actionUndo = QAction(MainWindow)
self.actionUndo.setObjectName(u"actionUndo") self.actionUndo.setObjectName(u"actionUndo")
icon4 = QIcon() icon5 = QIcon()
icon4.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon4) self.actionUndo.setIcon(icon5)
self.actionRedo = QAction(MainWindow) self.actionRedo = QAction(MainWindow)
self.actionRedo.setObjectName(u"actionRedo") self.actionRedo.setObjectName(u"actionRedo")
icon5 = QIcon() icon6 = QIcon()
icon5.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon5) self.actionRedo.setIcon(icon6)
self.actionCut = QAction(MainWindow) self.actionCut = QAction(MainWindow)
self.actionCut.setObjectName(u"actionCut") self.actionCut.setObjectName(u"actionCut")
icon6 = QIcon() icon7 = QIcon()
icon6.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon7.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon6) self.actionCut.setIcon(icon7)
self.actionCopy = QAction(MainWindow) self.actionCopy = QAction(MainWindow)
self.actionCopy.setObjectName(u"actionCopy") self.actionCopy.setObjectName(u"actionCopy")
icon7 = QIcon() icon8 = QIcon()
icon7.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon7) self.actionCopy.setIcon(icon8)
self.actionPaste = QAction(MainWindow) self.actionPaste = QAction(MainWindow)
self.actionPaste.setObjectName(u"actionPaste") self.actionPaste.setObjectName(u"actionPaste")
icon8 = QIcon() icon9 = QIcon()
icon8.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon8) self.actionPaste.setIcon(icon9)
self.actionSelectAll = QAction(MainWindow) self.actionSelectAll = QAction(MainWindow)
self.actionSelectAll.setObjectName(u"actionSelectAll") self.actionSelectAll.setObjectName(u"actionSelectAll")
self.actionDelete = QAction(MainWindow)
self.actionDelete.setObjectName(u"actionDelete")
self.actionAbout = QAction(MainWindow) self.actionAbout = QAction(MainWindow)
self.actionAbout.setObjectName(u"actionAbout") self.actionAbout.setObjectName(u"actionAbout")
self.actionSettings = QAction(MainWindow) self.actionSettings = QAction(MainWindow)
@@ -118,6 +130,24 @@ class Ui_MainWindow(object):
self.panel_libraries.setWidget(self.dockWidgetContents) self.panel_libraries.setWidget(self.dockWidgetContents)
self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_libraries) 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.workspaceSplitter.addWidget(self.leftDockHost)
self.workspace = QWidget(self.workspaceSplitter) self.workspace = QWidget(self.workspaceSplitter)
self.workspace.setObjectName(u"workspace") self.workspace.setObjectName(u"workspace")
@@ -126,9 +156,107 @@ class Ui_MainWindow(object):
sizePolicy1.setVerticalStretch(0) sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.workspace.sizePolicy().hasHeightForWidth()) sizePolicy1.setHeightForWidth(self.workspace.sizePolicy().hasHeightForWidth())
self.workspace.setSizePolicy(sizePolicy1) self.workspace.setSizePolicy(sizePolicy1)
self.workspace.setStyleSheet(u"QWidget#workspace {\n" self.workspaceEditorLayout = QVBoxLayout(self.workspace)
" background-color: rgb(198, 198, 198);\n" 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.workspaceSplitter.addWidget(self.workspace)
self.workspaceLayout.addWidget(self.workspaceSplitter) self.workspaceLayout.addWidget(self.workspaceSplitter)
@@ -164,6 +292,9 @@ class Ui_MainWindow(object):
self.editToolbar = QToolBar(MainWindow) self.editToolbar = QToolBar(MainWindow)
self.editToolbar.setObjectName(u"editToolbar") self.editToolbar.setObjectName(u"editToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.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.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction()) self.menubar.addAction(self.menuEdit.menuAction())
@@ -175,6 +306,7 @@ class Ui_MainWindow(object):
self.menuFile.addAction(self.actionSave) self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs) self.menuFile.addAction(self.actionSaveAs)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.actionClose)
self.menuFile.addAction(self.actionExit) self.menuFile.addAction(self.actionExit)
self.menuEdit.addAction(self.actionUndo) self.menuEdit.addAction(self.actionUndo)
self.menuEdit.addAction(self.actionRedo) self.menuEdit.addAction(self.actionRedo)
@@ -182,6 +314,8 @@ class Ui_MainWindow(object):
self.menuEdit.addAction(self.actionCopy) self.menuEdit.addAction(self.actionCopy)
self.menuEdit.addAction(self.actionCut) self.menuEdit.addAction(self.actionCut)
self.menuEdit.addAction(self.actionPaste) self.menuEdit.addAction(self.actionPaste)
self.menuEdit.addAction(self.actionDelete)
self.menuEdit.addAction(self.actionSelectAll)
self.menuEdit.addSeparator() self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionSettings) self.menuEdit.addAction(self.actionSettings)
self.menuView.addAction(self.menuPanels.menuAction()) self.menuView.addAction(self.menuPanels.menuAction())
@@ -197,9 +331,13 @@ class Ui_MainWindow(object):
self.editToolbar.addAction(self.actionCopy) self.editToolbar.addAction(self.actionCopy)
self.editToolbar.addAction(self.actionCut) self.editToolbar.addAction(self.actionCut)
self.editToolbar.addAction(self.actionPaste) self.editToolbar.addAction(self.actionPaste)
self.transformToolbar.addAction(self.actionRotateClockwise)
self.retranslateUi(MainWindow) self.retranslateUi(MainWindow)
self.workspaceStack.setCurrentIndex(0)
QMetaObject.connectSlotsByName(MainWindow) QMetaObject.connectSlotsByName(MainWindow)
# setupUi # setupUi
@@ -211,6 +349,13 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip) #endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut) #if QT_CONFIG(shortcut)
self.actionNew.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None)) 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) #endif // QT_CONFIG(shortcut)
self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None)) self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None))
#if QT_CONFIG(statustip) #if QT_CONFIG(statustip)
@@ -233,6 +378,10 @@ class Ui_MainWindow(object):
self.actionExit.setText(QCoreApplication.translate("MainWindow", u"E&xit", None)) self.actionExit.setText(QCoreApplication.translate("MainWindow", u"E&xit", None))
#if QT_CONFIG(shortcut) #if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None)) 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) #endif // QT_CONFIG(shortcut)
self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"&Undo", None)) self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"&Undo", None))
#if QT_CONFIG(shortcut) #if QT_CONFIG(shortcut)
@@ -257,6 +406,10 @@ class Ui_MainWindow(object):
self.actionSelectAll.setText(QCoreApplication.translate("MainWindow", u"Select &All", None)) self.actionSelectAll.setText(QCoreApplication.translate("MainWindow", u"Select &All", None))
#if QT_CONFIG(shortcut) #if QT_CONFIG(shortcut)
self.actionSelectAll.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+A", None)) 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) #endif // QT_CONFIG(shortcut)
self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"&About BEdit", None)) self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"&About BEdit", None))
self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"&Settings\u2026", None)) self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"&Settings\u2026", None))
@@ -265,6 +418,25 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip) #endif // QT_CONFIG(statustip)
self.actionAboutQt.setText(QCoreApplication.translate("MainWindow", u"About &Qt", None)) self.actionAboutQt.setText(QCoreApplication.translate("MainWindow", u"About &Qt", None))
self.panel_libraries.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", 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.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None))
self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None)) self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None))
self.menuView.setTitle(QCoreApplication.translate("MainWindow", u"&View", 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.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"&Help", None))
self.fileToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"File", None)) self.fileToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"File", None))
self.editToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Edit", None)) self.editToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Edit", None))
self.transformToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Transform", None))
# retranslateUi # retranslateUi

View File

@@ -16,8 +16,10 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QImage, QKeySequence, QLinearGradient, QPainter, QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform) QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox,
QFormLayout, QGroupBox, QSizePolicy, QSpacerItem, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QSpinBox, QTabWidget, QVBoxLayout, QWidget) QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSpacerItem, QSpinBox, QTabWidget, QVBoxLayout,
QWidget)
class Ui_SettingsDialog(object): class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog): def setupUi(self, SettingsDialog):
@@ -55,6 +57,46 @@ class Ui_SettingsDialog(object):
self.generalLayout.addItem(self.generalSpacer) self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "") 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) self.dialogLayout.addWidget(self.settingsTabs)
@@ -81,5 +123,10 @@ class Ui_SettingsDialog(object):
self.autosaveGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Automatic saving", None)) self.autosaveGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Automatic saving", None))
self.autosaveIntervalSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" minutes", 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.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 # 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()

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ComponentOptionsDialog</class>
<widget class="QDialog" name="ComponentOptionsDialog">
<property name="geometry"><rect><x>0</x><y>0</y><width>420</width><height>260</height></rect></property>
<property name="windowTitle"><string>Component Options</string></property>
<layout class="QVBoxLayout" name="dialogLayout">
<item>
<layout class="QFormLayout" name="optionsForm">
<item row="0" column="0"><widget class="QLabel" name="nameLabel"><property name="text"><string>Name:</string></property></widget></item>
<item row="0" column="1"><widget class="QLineEdit" name="nameEdit"/></item>
<item row="1" column="0"><widget class="QLabel" name="shapeLabel"><property name="text"><string>Icon shape:</string></property></widget></item>
<item row="1" column="1"><widget class="QComboBox" name="shapeCombo"><item><property name="text"><string>rectangle</string></property></item><item><property name="text"><string>ellipse</string></property></item></widget></item>
<item row="2" column="0"><widget class="QLabel" name="iconTextLabel"><property name="text"><string>Icon text:</string></property></widget></item>
<item row="2" column="1"><widget class="QLineEdit" name="iconTextEdit"/></item>
<item row="3" column="0"><widget class="QLabel" name="fillLabel"><property name="text"><string>Fill color:</string></property></widget></item>
<item row="3" column="1"><widget class="QLineEdit" name="fillEdit"><property name="placeholderText"><string>#dbeafe</string></property></widget></item>
<item row="4" column="0"><widget class="QLabel" name="borderLabel"><property name="text"><string>Border color:</string></property></widget></item>
<item row="4" column="1"><widget class="QLineEdit" name="borderEdit"><property name="placeholderText"><string>#303030</string></property></widget></item>
<item row="5" column="0" colspan="2"><widget class="QCheckBox" name="showSubtreeCheckBox"><property name="text"><string>Show contained components in the Libraries tree</string></property><property name="checked"><bool>true</bool></property></widget></item>
</layout>
</item>
<item><spacer name="optionsSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>40</height></size></property></spacer></item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
</layout>
</widget>
<resources/>
<connections>
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>ComponentOptionsDialog</receiver><slot>accept()</slot><hints/></connection>
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>ComponentOptionsDialog</receiver><slot>reject()</slot><hints/></connection>
</connections>
</ui>

View File

@@ -94,19 +94,91 @@
</layout> </layout>
</widget> </widget>
</widget> </widget>
<widget class="QDockWidget" name="panel_document">
<property name="minimumSize">
<size><width>220</width><height>91</height></size>
</property>
<property name="windowTitle"><string>Document</string></property>
<attribute name="dockWidgetArea"><number>1</number></attribute>
<widget class="QWidget" name="documentDockContents">
<layout class="QVBoxLayout" name="documentPanelLayout">
<property name="spacing"><number>0</number></property>
<property name="leftMargin"><number>0</number></property>
<property name="topMargin"><number>0</number></property>
<property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
<item>
<widget class="QTreeView" name="documentTreeView">
<property name="alternatingRowColors"><bool>true</bool></property>
<property name="uniformRowHeights"><bool>true</bool></property>
</widget>
</item>
</layout>
</widget>
</widget>
</widget> </widget>
<widget class="QWidget" name="workspace" native="true"> <widget class="QWidget" name="workspace">
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred"> <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>1</horstretch> <horstretch>1</horstretch>
<verstretch>0</verstretch> <verstretch>0</verstretch>
</sizepolicy> </sizepolicy>
</property> </property>
<property name="styleSheet"> <layout class="QVBoxLayout" name="workspaceEditorLayout">
<string notr="true">QWidget#workspace { <property name="spacing"><number>0</number></property>
background-color: rgb(198, 198, 198); <property name="leftMargin"><number>0</number></property>
}</string> <property name="topMargin"><number>0</number></property>
</property> <property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
<item>
<widget class="QFrame" name="workspaceHeader">
<property name="minimumSize"><size><width>0</width><height>34</height></size></property>
<property name="maximumSize"><size><width>16777215</width><height>34</height></size></property>
<property name="frameShape"><enum>QFrame::Shape::StyledPanel</enum></property>
<layout class="QHBoxLayout" name="workspaceHeaderLayout">
<property name="leftMargin"><number>6</number></property>
<property name="topMargin"><number>2</number></property>
<property name="rightMargin"><number>6</number></property>
<property name="bottomMargin"><number>2</number></property>
<item><widget class="QToolButton" name="navigateUpButton"><property name="text"><string>Up</string></property><property name="toolTip"><string>Open the containing graph</string></property></widget></item>
<item><widget class="QLabel" name="graphBreadcrumbLabel"><property name="text"><string>Untitled</string></property></widget></item>
<item><widget class="QLabel" name="workspaceModeLabel"><property name="text"><string>Graph</string></property></widget></item>
<item><spacer name="workspaceHeaderSpacer"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QPushButton" name="applyJsonButton"><property name="text"><string>Apply JSON</string></property><property name="visible"><bool>false</bool></property></widget></item>
<item><widget class="QToolButton" name="pointerToolButton"><property name="text"><string>Pointer</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><property name="autoExclusive"><bool>true</bool></property></widget></item>
<item><widget class="QToolButton" name="inputToolButton"><property name="text"><string>Input</string></property><property name="toolTip"><string>Add an interface input</string></property><property name="checkable"><bool>true</bool></property><property name="autoExclusive"><bool>true</bool></property></widget></item>
<item><widget class="QToolButton" name="outputToolButton"><property name="text"><string>Output</string></property><property name="toolTip"><string>Add an interface output</string></property><property name="checkable"><bool>true</bool></property><property name="autoExclusive"><bool>true</bool></property></widget></item>
</layout>
</widget>
</item>
<item>
<widget class="QStackedWidget" name="workspaceStack">
<property name="currentIndex"><number>0</number></property>
<widget class="QWidget" name="graphPage">
<layout class="QVBoxLayout" name="graphPageLayout">
<property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="GraphWorkspaceView" name="graphView"/></item>
</layout>
</widget>
<widget class="QWidget" name="jsonPage">
<layout class="QVBoxLayout" name="jsonPageLayout">
<property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QPlainTextEdit" name="jsonEditor"><property name="lineWrapMode"><enum>QPlainTextEdit::LineWrapMode::NoWrap</enum></property><property name="placeholderText"><string>Component JSON</string></property></widget></item>
</layout>
</widget>
<widget class="QWidget" name="emptyPage">
<layout class="QVBoxLayout" name="emptyPageLayout">
<item>
<widget class="QLabel" name="emptyWorkspaceLabel">
<property name="text"><string>No document open</string></property>
<property name="alignment"><set>Qt::AlignmentFlag::AlignCenter</set></property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget> </widget>
</widget> </widget>
</item> </item>
@@ -131,6 +203,7 @@
<addaction name="actionSave"/> <addaction name="actionSave"/>
<addaction name="actionSaveAs"/> <addaction name="actionSaveAs"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionClose"/>
<addaction name="actionExit"/> <addaction name="actionExit"/>
</widget> </widget>
<widget class="QMenu" name="menuEdit"> <widget class="QMenu" name="menuEdit">
@@ -143,6 +216,8 @@
<addaction name="actionCopy"/> <addaction name="actionCopy"/>
<addaction name="actionCut"/> <addaction name="actionCut"/>
<addaction name="actionPaste"/> <addaction name="actionPaste"/>
<addaction name="actionDelete"/>
<addaction name="actionSelectAll"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSettings"/> <addaction name="actionSettings"/>
</widget> </widget>
@@ -230,6 +305,12 @@
<addaction name="actionCut"/> <addaction name="actionCut"/>
<addaction name="actionPaste"/> <addaction name="actionPaste"/>
</widget> </widget>
<widget class="QToolBar" name="transformToolbar">
<property name="windowTitle"><string>Transform</string></property>
<attribute name="toolBarArea"><enum>TopToolBarArea</enum></attribute>
<attribute name="toolBarBreak"><bool>false</bool></attribute>
<addaction name="actionRotateClockwise"/>
</widget>
<action name="actionNew"> <action name="actionNew">
<property name="icon"> <property name="icon">
<iconset resource="../resources/resources.qrc"> <iconset resource="../resources/resources.qrc">
@@ -245,6 +326,15 @@
<string>Ctrl+N</string> <string>Ctrl+N</string>
</property> </property>
</action> </action>
<action name="actionRotateClockwise">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/transform-rotate.png</normaloff>:/icons/icons/transform-rotate.png</iconset>
</property>
<property name="text"><string>Rotate Clockwise</string></property>
<property name="toolTip"><string>Rotate selected blocks clockwise by 90 degrees</string></property>
<property name="shortcut"><string>Ctrl+R</string></property>
</action>
<action name="actionOpen"> <action name="actionOpen">
<property name="icon"> <property name="icon">
<iconset resource="../resources/resources.qrc"> <iconset resource="../resources/resources.qrc">
@@ -295,6 +385,10 @@
<string>Ctrl+Q</string> <string>Ctrl+Q</string>
</property> </property>
</action> </action>
<action name="actionClose">
<property name="text"><string>&amp;Close Document</string></property>
<property name="shortcut"><string>Ctrl+W</string></property>
</action>
<action name="actionUndo"> <action name="actionUndo">
<property name="icon"> <property name="icon">
<iconset resource="../resources/resources.qrc"> <iconset resource="../resources/resources.qrc">
@@ -363,6 +457,10 @@
<string>Ctrl+A</string> <string>Ctrl+A</string>
</property> </property>
</action> </action>
<action name="actionDelete">
<property name="text"><string>&amp;Delete</string></property>
<property name="shortcut"><string>Del</string></property>
</action>
<action name="actionAbout"> <action name="actionAbout">
<property name="text"> <property name="text">
<string>&amp;About BEdit</string> <string>&amp;About BEdit</string>
@@ -382,6 +480,13 @@
</property> </property>
</action> </action>
</widget> </widget>
<customwidgets>
<customwidget>
<class>GraphWorkspaceView</class>
<extends>QGraphicsView</extends>
<header>bedit.workspace.view</header>
</customwidget>
</customwidgets>
<resources> <resources>
<include location="../resources/resources.qrc"/> <include location="../resources/resources.qrc"/>
</resources> </resources>

View File

@@ -73,6 +73,51 @@
</item> </item>
</layout> </layout>
</widget> </widget>
<widget class="QWidget" name="librariesTab">
<attribute name="title">
<string>Libraries</string>
</attribute>
<layout class="QVBoxLayout" name="librariesTabLayout">
<item>
<widget class="QLabel" name="libraryPathsLabel">
<property name="text">
<string>Load library JSON files from these files or folders at startup:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="libraryPathsList"/>
</item>
<item>
<layout class="QHBoxLayout" name="libraryPathButtonsLayout">
<item>
<widget class="QPushButton" name="addLibraryFileButton">
<property name="text"><string>Add File…</string></property>
</widget>
</item>
<item>
<widget class="QPushButton" name="addLibraryFolderButton">
<property name="text"><string>Add Folder…</string></property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeLibraryPathButton">
<property name="text"><string>Remove</string></property>
</widget>
</item>
<item>
<spacer name="libraryButtonsSpacer">
<property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property>
<property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</widget> </widget>
</item> </item>
<item> <item>