\xdf0\x14\xf1\xcf\xe4\xc6\x06kkk\
+x\x9e\xc7\xe0\xe0\xd0o\x0b\xf9\xfc\x0f\xc0V\x1dC\xdd\
+\xdd\xdd\xdc\xbf\x7f\xffM\xff)\xfb\x80\xf7\xb6\xf1w]\
+\x00\x16\x81'@b\x8a\xfd\x0fg\xea\x16`\x16W\xbd\
+\x9b\x00\x00\x00\x00IEND\xaeB`\x82\
+\x00\x00\x04\xef\
+\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\x04sBIT\x08\x08\x08\x08|\x08d\x88\
+\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\
+\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\
+tware\x00www.inksca\
+pe.org\x9b\xee<\x1a\x00\x00\x04lID\
+ATx\xda\xc5V\xdfo\x1bE\x10\x9e\xfbm\xc7v\
+b\xb7v\x13\x0a\x11\x22\x05\xa9\xa2\xef\xbc \xf1\x14\xde\
+\xf87\x80g\xd4\x97\x0a\x89\xf2\x04\xadT\xf8\x7f\x90\x8a\
+\x04\xaa*\xb5oE4\x84\x22\x88\xd4\x22L\x8c\x93\xda\
+q\xb1\xef\xec\xbb\xdb[fF\x1e\xfbzw\xe6B\xa4\
+\xd2M\xd63\xbbw7\xf3\xed7\xdf\xee\x9d\xad\xb5\x86\
+\x97\xd9\xec\xb95\xb0\xeb\x97\x09@_\xfd\xe4\xea\xedD\
+\xab]\xb9\xf0\xce\xbb\xef\xc1\xce\x9b\x97\xc1\xb1,\x80\x0c\
+K\x86\x81x\xb1\x9b\xa6\x01\xfcgH\xe7\x8bl\xf1\x12\
+\xcf\xc5Q\x0c{?\xfd\x08\xf7\xef\xde\x01i\xad\xd6\xb9\
+o\xaf\x7f~\xfd\xfd4\x00\xf0\xfd\xc9\xee\xc3\xbd\x87\xb0\
+\xb9\xb9\x05\x17:\x1d8\xdf\xb9\x00\xdb\xdb\xaf\x83\xe7:\
+\x85\xc8\x8d\xf9\x0fYN\x9e\xf5\x19\x84\x01\xfe,\x84~\
+\xff/\x98\x06S\x906H\x9e\xeef\x19\x80$I\xa0\
+V\xab\x81c\xdb\xec\x9b\xa0y\x85\xbf\xf7O\x8a\x92g\
+\x18y~`\xa4\x00\xbe\xd6n\xa0\xd1\x1c3\x9d\xab\x10\
+@s\xa3\x09\x9e\xe7-n\xb0,\x03\xdez\xb5\xc5\xbe\
+\xce&\xe7D\xe2\x8a\xe59a\x80\xc7\xc14\xa4K\xe5\
+\x00h74\x1a\x0d\xf1\xf9a\x0b\x19pm\x0bV\xd5\
+`5\x00\x89\x89\xc9\xa8C\xc21S\xb9\x0a\x19\xc8\xc4\
+\xd7\x08\x00o\xb0%\x9a\x5c(.\x83\x9e\x07\xa6\x84\x1a\
+\x7f\xd0\xe5q\xac\x14\x8f\xd3\xf1\x95R\xe5\x00\x12\x8d\xb8\
+\xf1\xc1\x9f\x1f\x1fCQ\xd3\x99\x91\xd6\xe21\x12\xb2<\
+\xd8:W\x03\x95\xe4JP\x0e\x80\x86\x11>\xb9\xbd\xb9\
+\xfe\xbc\xc84\xff\xe7\xe8$\xa3\x19\xc8\x82\x0d\xee\xb3P\
+\xd1\x8aO\xa7\x81tSq\x021u+\x0d\xac\x00\x88\
+\xac^\x92\xb2]\xfa1.Bi\xfd\xdf5\x10'\x0a\
+\x22\xda\x8e\x91Z]\x02Y\xbd\xb89\x06\x805\xa0\x12\
+}\x86\x12\xe0\xd3Q\xac\xa0w<\x96\xa92-H\xed\
+\xc5\xe5\xd6Z\xf7\x90MU.B\x9d\x01\xa0pL\xf4\
+5\x1b\x15\xd9W\xf9D\x99d<\xbb`\x80\x1d\x8a\xc1\
+\xc9\xd3\xf1u!\x039\x0dh\x16\xa1Q\xb2t\x9d\x06\
+%\xe3\xa5\x06\xc8a\x0d\xa4\xe3'\xa7\xd1\xc0\xbd\xbb\xdf\
+C\xaf\xd7\x83\xc0\xf7K\xe8/\x16\xa60S][\x83\
+'\xbf=\xcaj\xa0\xbc\x04O~\xfd\x85z\xfa\x0d\x98\
+\xb5\xc5\xa0V+\xbe\xa4\x048\xf9\x82[\xc9I\xf8\xff\
+}\x19\x15k@\xbfX\x06\xce^\x82\x0f?\xfe\x08\xde\
+\xd8\xd9)\xaa\xe5\xca:\x0f\x06\x03\xf8\xf4\xda50M\
+\x13\xe3j\xde\xd2\x9b\x9d\xce\xbf\x8b0\x8e\xe3\x5c`\xaf\
+\xe2\xb1\x8a\xc7\xe3\xb1\x08/+0\x9a\x93.s\xfca\
+S\xa9T\xc0\xb2,\xde\x09\xb3\xd9,\x1f_\x00\x14\x09\
+CZ\xfa\x04\xbb\xf1\xc5\x97t\xac\xca\xe7\x1b\xdc\xb8y\
+\x93V(\xa0\xe8>\xb2\xcb\x17\x0f\x832\x89o\xfa\xb2\
+\xa2\xf93\x00\x98\x07\xa5\xf6\xf4\xf8\x18\xa4\x0dON(\
+\x09\x01\xa0\x95\x91\xcf\xcfK\xd7\xcb\x17\x82\x98S\x00(\
+\xa0(\x9cN)x\x16 QK\xf3D-\xdb(\x8a\
+d\xe5\xc2\x02g\x150\x9a\x80!#\xd5j\x15j\xf5\
+:\x0c\x07\x83\x02\x0d\x14 \xb4l\xbb\x10@\x14G\x10\
+\x04\x81$ +L\x90\xe5\x1e\x22({\x0e4\xa6\x18\
+t\xaf\x01\x10\xe0\xa2\xa28>\x1d\x03\xe0\xba\x1c|\x8a\
+\x0f\xc5q$\xe5 +\xc2\xa2\xd5K\x09$9_\x8f\
+Bd\x87E\xa8\xf9\x85d\xa0\xb5M\x0b\x9e\x8dF\xd0\
+X_/\x02\xa0z`\xc0V\xf6\xc4\x0a\xfc\x00\x1c\xc7\
+\x811Z\x7f2a]4\x9bM\x14\xa2/I\x85\xfe\
+\x85\x0d\xc3\x10\x81\xd1|\xb2\x10\xa89\xdf\x9e\xedv\x1b\
+\xfa\xfd>\x18\xd846\x02\xc0\x83\xcb\x97.}P\xa9\
+\xd6\xbe\xc1R\xb5\xd3\xfbU\xe8\xbe\xf5\xd5-\x01E\x9d\
+\xb6&\x05\xcf\x89P\xfc(\x0cY+\x02\xc02M\x9e\
+\xefv\xbb,^lUL\x1b\x08\x03\xc6\xa3\x83\x83?\
+766\xbe\xde~\xe5\xe2gx\xc3\x1a\x8b0\x0a9\
+\x91=\xd7Bj\xc5D\xbd\xd0\x9f>\x8c(\xb8h*\
+]2\xd1\x90\x8cC4\xe7\xb1w\x05\x00\xdd\xf9\xf7h\
+4\xfa\x0e\x95\xdf\xf2<\xef\x22\x11\xf0\xf6\x95+\xd6\x0f\
+\x0f\x1e\xec\xb8\xae\xeb\xb9\x8e\xe3\xa2(]\x04\xe3a\x12\
+\x87\xca'\x87\x80\xe6/8\x15\x22\x980\xc26\x1c\x0e\
+}\xdb\xb1\x0f\x0e\x0f\x0fgx\xd5X\xe6`\xba\x15:\
+\xf7\xd0\x9ba\xe7\x12p\x0c\x8c\xe5\xa3\xbb\x17\xccf\x8f\
+\xb1;4\xfdG\xb7\x9bT\xaa\xd5\x10O\xb5D\xd0#\
+K\x80@\x89\x15\x8d\xc04\xd5\xb5^\xaf\x1bx\xfa\x19\
+\x84\xe7\x04\xcf\x88\xfd\xfd\xfd\xe4\xe8\xe8\xc8\x9dL&\x14\
+\xc7\xcc\x7f5p\xf2g\x94\xf7\x1f\xdf\x9a\xd2\x93\xfbC\
+\xb7\xa7\x00\x00\x00\x00IEND\xaeB`\x82\
+"
+
+qt_resource_name = b"\
+\x00\x05\
+\x00o\xa6S\
+\x00i\
+\x00c\x00o\x00n\x00s\
+\x00\x10\
+\x0c\xbc.g\
+\x00d\
+\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00n\x00e\x00w\x00.\x00p\x00n\x00g\
+\x00\x14\
+\x0b\xa9\xab'\
+\x00d\
+\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\
+\x00p\x00n\x00g\
+\x00\x14\
+\x0c\xbb2g\
+\x00t\
+\x00r\x00a\x00n\x00s\x00f\x00o\x00r\x00m\x00-\x00r\x00o\x00t\x00a\x00t\x00e\x00.\
+\x00p\x00n\x00g\
+\x00\x0d\
+\x03\xd2\xbeg\
+\x00e\
+\x00d\x00i\x00t\x00-\x00u\x00n\x00d\x00o\x00.\x00p\x00n\x00g\
+\x00\x0c\
+\x07\xb1Y'\
+\x00e\
+\x00d\x00i\x00t\x00-\x00c\x00u\x00t\x00.\x00p\x00n\x00g\
+\x00\x0d\
+\x01\x1c\xb1\xa7\
+\x00e\
+\x00d\x00i\x00t\x00-\x00c\x00o\x00p\x00y\x00.\x00p\x00n\x00g\
+\x00\x0d\
+\x0c\xd2\xbf\xe7\
+\x00e\
+\x00d\x00i\x00t\x00-\x00r\x00e\x00d\x00o\x00.\x00p\x00n\x00g\
+\x00\x11\
+\x01\xa6\xc4\x87\
+\x00d\
+\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00o\x00p\x00e\x00n\x00.\x00p\x00n\x00g\
+\
+\x00\x0e\
+\x0c\xaa\xc0\xa7\
+\x00e\
+\x00d\x00i\x00t\x00-\x00p\x00a\x00s\x00t\x00e\x00.\x00p\x00n\x00g\
+\x00\x11\
+\x0f\xe3\xd5g\
+\x00d\
+\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00.\x00p\x00n\x00g\
+\
+"
+
+qt_resource_struct = b"\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0a\x00\x00\x00\x03\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xfa\
+\x00\x00\x01\x9f{C\xf1'\
+\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00+,\
+\x00\x00\x01\x9f{0\xc99\
+\x00\x00\x00\x92\x00\x00\x00\x00\x00\x01\x00\x00\x14\x92\
+\x00\x00\x01\x9f{C\xf1\x18\
+\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x1cz\
+\x00\x00\x01\x9f{C\xf1.\
+\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\
+\x00\x00\x01\x9f{0\xc9B\
+\x00\x00\x018\x00\x00\x00\x00\x00\x01\x00\x0026\
+\x00\x00\x01\x9f{C\xf1N\
+\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\
+\x00\x00\x01\x9f{\x8d\xf34\
+\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
+\x00\x00\x01\x9f{0\xc9+\
+\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00#Z\
+\x00\x00\x01\x9f{C\xf1=\
+\x00\x00\x01Z\x00\x00\x00\x00\x00\x01\x00\x007\xb8\
+\x00\x00\x01\x9f{0\xc9R\
+"
+
+def qInitResources():
+ QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data)
+
+def qCleanupResources():
+ QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data)
+
+qInitResources()
diff --git a/BEdit/src/bedit/app.py b/BEdit/src/bedit/app.py
index 6024441..68d7ccb 100644
--- a/BEdit/src/bedit/app.py
+++ b/BEdit/src/bedit/app.py
@@ -1,7 +1,7 @@
import sys
from PySide6.QtCore import QCoreApplication, Qt
-from PySide6.QtGui import QColor, QPalette, QIcon
+from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication, QStyleFactory
from bedit.main_window import MainWindow
diff --git a/BEdit/src/bedit/component_options_dialog.py b/BEdit/src/bedit/component_options_dialog.py
new file mode 100644
index 0000000..85bc9af
--- /dev/null
+++ b/BEdit/src/bedit/component_options_dialog.py
@@ -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()
diff --git a/BEdit/src/bedit/data/libraries/example.json b/BEdit/src/bedit/data/libraries/example.json
new file mode 100644
index 0000000..39da028
--- /dev/null
+++ b/BEdit/src/bedit/data/libraries/example.json
@@ -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": []}}
+ }
+ ]
+}
diff --git a/BEdit/src/bedit/data/libraries/test.bedit.json b/BEdit/src/bedit/data/libraries/test.bedit.json
new file mode 100644
index 0000000..88faea7
--- /dev/null
+++ b/BEdit/src/bedit/data/libraries/test.bedit.json
@@ -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": []
+ }
+ }
+ }
+ ]
+}
diff --git a/BEdit/src/bedit/document/__init__.py b/BEdit/src/bedit/document/__init__.py
new file mode 100644
index 0000000..33fe6a5
--- /dev/null
+++ b/BEdit/src/bedit/document/__init__.py
@@ -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",
+]
diff --git a/BEdit/src/bedit/document/commands.py b/BEdit/src/bedit/document/commands.py
new file mode 100644
index 0000000..b3e2122
--- /dev/null
+++ b/BEdit/src/bedit/document/commands.py
@@ -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)
diff --git a/BEdit/src/bedit/document/controller.py b/BEdit/src/bedit/document/controller.py
new file mode 100644
index 0000000..01818c0
--- /dev/null
+++ b/BEdit/src/bedit/document/controller.py
@@ -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()
diff --git a/BEdit/src/bedit/document/model.py b/BEdit/src/bedit/document/model.py
new file mode 100644
index 0000000..aae44e6
--- /dev/null
+++ b/BEdit/src/bedit/document/model.py
@@ -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)
diff --git a/BEdit/src/bedit/document/serializer.py b/BEdit/src/bedit/document/serializer.py
new file mode 100644
index 0000000..b810360
--- /dev/null
+++ b/BEdit/src/bedit/document/serializer.py
@@ -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)
+
diff --git a/BEdit/src/bedit/item_options_dialog.py b/BEdit/src/bedit/item_options_dialog.py
new file mode 100644
index 0000000..5227c49
--- /dev/null
+++ b/BEdit/src/bedit/item_options_dialog.py
@@ -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()
diff --git a/BEdit/src/bedit/library/__init__.py b/BEdit/src/bedit/library/__init__.py
new file mode 100644
index 0000000..a73536b
--- /dev/null
+++ b/BEdit/src/bedit/library/__init__.py
@@ -0,0 +1,3 @@
+from bedit.library.repository import LibraryRepository, default_library_paths
+
+__all__ = ["LibraryRepository", "default_library_paths"]
diff --git a/BEdit/src/bedit/library/repository.py b/BEdit/src/bedit/library/repository.py
new file mode 100644
index 0000000..448a85a
--- /dev/null
+++ b/BEdit/src/bedit/library/repository.py
@@ -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))
diff --git a/BEdit/src/bedit/library/tree_model.py b/BEdit/src/bedit/library/tree_model.py
new file mode 100644
index 0000000..3235adb
--- /dev/null
+++ b/BEdit/src/bedit/library/tree_model.py
@@ -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()
diff --git a/BEdit/src/bedit/main_window.py b/BEdit/src/bedit/main_window.py
index 79325b3..b33ba77 100644
--- a/BEdit/src/bedit/main_window.py
+++ b/BEdit/src/bedit/main_window.py
@@ -1,13 +1,27 @@
+import json
+from pathlib import Path
+
from PySide6.QtCore import QSettings, Qt, Slot
from PySide6.QtGui import QCloseEvent
-from PySide6.QtWidgets import QMainWindow, QMessageBox
+from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox
+from bedit.component_options_dialog import ComponentOptionsDialog
+from bedit.document.controller import DocumentController
+from bedit.document.model import Port
+from bedit.item_options_dialog import ItemOptionsDialog
+from bedit.library.repository import LibraryRepository
+from bedit.library.tree_model import (
+ COMPONENT_ID_ROLE,
+ ITEM_KIND_ROLE,
+ DocumentTreeModel,
+ LibraryTreeModel,
+)
from bedit.settings_dialog import SettingsDialog
from bedit.ui_main_window import Ui_MainWindow
class MainWindow(QMainWindow):
- """Application shell for the window laid out in Qt Designer."""
+ """Application shell and owner of the single active document."""
def __init__(self) -> None:
super().__init__()
@@ -15,44 +29,447 @@ class MainWindow(QMainWindow):
self.ui.setupUi(self)
self.settings = QSettings()
+ self.libraries = LibraryRepository(self)
+ self.document_controller = DocumentController(self)
+ self.library_tree_model = LibraryTreeModel(
+ self.libraries,
+ self.document_controller,
+ self,
+ )
+ self.document_tree_model = DocumentTreeModel(self.document_controller, self)
+ self._configure_models()
+ self._connect_actions()
self._populate_view_menu()
- self.ui.actionExit.triggered.connect(self.close)
- self.ui.actionSettings.triggered.connect(self.show_settings)
- self.ui.actionAbout.triggered.connect(self.show_about)
- self.ui.actionAboutQt.triggered.connect(lambda: QMessageBox.aboutQt(self, "About Qt Framework"))
self._restore_window_geometry()
+
self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget)
self.ui.leftDockHost.show()
self.ui.panel_libraries.show()
+ self.ui.panel_document.show()
+ self.ui.leftDockHost.splitDockWidget(
+ self.ui.panel_document,
+ self.ui.panel_libraries,
+ Qt.Orientation.Vertical,
+ )
self.ui.workspaceSplitter.setSizes([280, 720])
+ self.reload_libraries()
+ self._active_graph_changed()
+ self._update_title()
+
+ def _configure_models(self) -> None:
+ self.ui.treeView.setModel(self.library_tree_model)
+ self.ui.treeView.setHeaderHidden(True)
+ self.ui.treeView.setDragEnabled(True)
+ self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
+ self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll)
+ self.ui.documentTreeView.setModel(self.document_tree_model)
+ self.ui.documentTreeView.setHeaderHidden(True)
+ self.ui.documentTreeView.setDragEnabled(True)
+ self.ui.documentTreeView.setDragDropMode(
+ self.ui.documentTreeView.DragDropMode.DragOnly
+ )
+ self.ui.documentTreeView.setContextMenuPolicy(
+ Qt.ContextMenuPolicy.CustomContextMenu
+ )
+ self.ui.documentTreeView.customContextMenuRequested.connect(
+ self.show_library_context_menu
+ )
+ self.ui.documentTreeView.doubleClicked.connect(self.activate_tree_component)
+ self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
+ self.ui.graphView.set_model(self.document_controller)
+ self.ui.graphView.componentOptionsRequested.connect(self.show_component_options)
+ self.ui.graphView.portOptionsRequested.connect(self.show_port_options)
+ self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options)
+ self.ui.graphView.selectionAvailabilityChanged.connect(
+ lambda _available: self._update_edit_actions()
+ )
+ self.ui.navigateUpButton.clicked.connect(self.navigate_up)
+ self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
+ self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input"))
+ self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output"))
+ self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer"))
+ self.ui.applyJsonButton.clicked.connect(self.apply_json)
+ self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
+
+ def _connect_actions(self) -> None:
+ self.ui.actionNew.triggered.connect(self.new_document)
+ self.ui.actionOpen.triggered.connect(self.open_document)
+ self.ui.actionSave.triggered.connect(self.save_document)
+ self.ui.actionSaveAs.triggered.connect(self.save_document_as)
+ self.ui.actionClose.triggered.connect(self.close_document)
+ self.ui.actionExit.triggered.connect(self.close)
+ self.ui.actionSettings.triggered.connect(self.show_settings)
+ self.ui.actionAbout.triggered.connect(self.show_about)
+ self.ui.actionAboutQt.triggered.connect(
+ lambda: QMessageBox.aboutQt(self, "About Qt Framework")
+ )
+ self.ui.actionUndo.triggered.connect(self.document_controller.undo_stack.undo)
+ self.ui.actionRedo.triggered.connect(self.document_controller.undo_stack.redo)
+ self.ui.actionDelete.triggered.connect(self.ui.graphView.delete_selected)
+ self.ui.actionCopy.triggered.connect(self.ui.graphView.copy_selection)
+ self.ui.actionCut.triggered.connect(self.ui.graphView.cut_selection)
+ self.ui.actionPaste.triggered.connect(self.ui.graphView.paste_selection)
+ self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all)
+ self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected)
+ self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
+ self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
+ self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title())
+ self.document_controller.filePathChanged.connect(lambda _path: self._update_title())
+ self.document_controller.documentOpenedChanged.connect(self._document_opened_changed)
+ self.ui.actionUndo.setEnabled(False)
+ self.ui.actionRedo.setEnabled(False)
+ self._update_edit_actions()
+ self._document_opened_changed(False)
def _populate_view_menu(self) -> None:
- """Add checked show/hide actions for panels and toolbars."""
- panels = (self.ui.panel_libraries,)
- for panel in panels:
+ for panel in (self.ui.panel_document, self.ui.panel_libraries):
self.ui.menuPanels.addAction(panel.toggleViewAction())
-
- toolbars = (self.ui.fileToolbar, self.ui.editToolbar)
- for toolbar in toolbars:
+ for toolbar in (
+ self.ui.fileToolbar,
+ self.ui.editToolbar,
+ self.ui.transformToolbar,
+ ):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
+ def reload_libraries(self) -> None:
+ self.libraries.load_paths(SettingsDialog.library_paths(self.settings))
+ self.ui.treeView.expandAll()
+ if self.libraries.load_warnings:
+ QMessageBox.warning(
+ self,
+ "Some libraries could not be loaded",
+ "\n".join(self.libraries.load_warnings),
+ )
+
def _restore_window_geometry(self) -> None:
geometry = self.settings.value("window/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
+ def _update_title(self) -> None:
+ if self.document_controller.document is None:
+ self.setWindowTitle("BEdit")
+ return
+ name = self.document_controller.file_path.name if self.document_controller.file_path else "Untitled"
+ modified = "*" if not self.document_controller.undo_stack.isClean() else ""
+ self.setWindowTitle(f"{modified}{name} — BEdit")
+
+ def _active_graph_changed(self) -> None:
+ component = self.document_controller.active_component
+ if component is None:
+ self.ui.graphBreadcrumbLabel.setText("No component selected")
+ self.ui.navigateUpButton.setEnabled(False)
+ self.ui.workspaceModeLabel.setText("")
+ self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage)
+ self.ui.applyJsonButton.setVisible(False)
+ for button in (
+ self.ui.pointerToolButton,
+ self.ui.inputToolButton,
+ self.ui.outputToolButton,
+ ):
+ button.setVisible(False)
+ self._update_edit_actions()
+ return
+ self.ui.graphBreadcrumbLabel.setText(" › ".join(self.document_controller.breadcrumb()))
+ self.ui.navigateUpButton.setEnabled(
+ self.document_controller.document.find_parent(component.id) is not None
+ )
+ is_graph = component.implementation_kind == "graph"
+ self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
+ self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage)
+ self.ui.applyJsonButton.setVisible(not is_graph)
+ for button in (
+ self.ui.pointerToolButton,
+ self.ui.inputToolButton,
+ self.ui.outputToolButton,
+ ):
+ button.setVisible(is_graph)
+ if is_graph:
+ self.set_graph_tool("pointer")
+ else:
+ self._load_source_json()
+ self._update_edit_actions()
+
+ def _update_edit_actions(self) -> None:
+ component = self.document_controller.active_component
+ is_graph = component is not None and component.implementation_kind == "graph"
+ has_selection = bool(self.ui.graphView.scene() and self.ui.graphView.scene().selectedItems())
+ for action in (self.ui.actionCut, self.ui.actionCopy, self.ui.actionDelete):
+ action.setEnabled(is_graph and has_selection)
+ self.ui.actionRotateClockwise.setEnabled(
+ is_graph and self.ui.graphView.has_selected_components()
+ )
+ self.ui.actionSelectAll.setEnabled(is_graph)
+ self.ui.actionPaste.setEnabled(is_graph)
+
+ @Slot()
+ def navigate_up(self) -> None:
+ if self._resolve_source_edits():
+ self.document_controller.navigate_up()
+
+ def set_graph_tool(self, mode: str) -> None:
+ self.ui.graphView.set_tool_mode(mode)
+ buttons = {
+ "pointer": self.ui.pointerToolButton,
+ "input": self.ui.inputToolButton,
+ "output": self.ui.outputToolButton,
+ }
+ buttons[mode].setChecked(True)
+
+ def _load_source_json(self) -> None:
+ component = self.document_controller.active_component
+ if component is None:
+ return
+ text = json.dumps(
+ {
+ "inputs": [port.to_dict() for port in component.inputs],
+ "outputs": [port.to_dict() for port in component.outputs],
+ "source": component.source,
+ },
+ indent=2,
+ )
+ self.ui.jsonEditor.setPlainText(text)
+ self.ui.jsonEditor.document().setModified(False)
+
+ def _resolve_source_edits(self) -> bool:
+ component = self.document_controller.active_component
+ if (
+ component is None
+ or component.implementation_kind != "text"
+ or not self.ui.jsonEditor.document().isModified()
+ ):
+ return True
+ answer = QMessageBox.question(
+ self,
+ "Apply text component changes?",
+ "The text component has unapplied input, output, or source changes.",
+ QMessageBox.StandardButton.Apply
+ | QMessageBox.StandardButton.Discard
+ | QMessageBox.StandardButton.Cancel,
+ )
+ if answer == QMessageBox.StandardButton.Apply:
+ return self.apply_json()
+ return answer == QMessageBox.StandardButton.Discard
+
+ @Slot()
+ def apply_json(self) -> bool:
+ try:
+ data = json.loads(self.ui.jsonEditor.toPlainText())
+ if not isinstance(data, dict):
+ raise ValueError("The text component JSON must be an object")
+ if not isinstance(data.get("inputs"), list):
+ raise ValueError("'inputs' must be a list")
+ if not isinstance(data.get("outputs"), list):
+ raise ValueError("'outputs' must be a list")
+ if not isinstance(data.get("source"), dict):
+ raise ValueError("'source' must be an object")
+ inputs = [Port.from_dict(item) for item in data["inputs"]]
+ outputs = [Port.from_dict(item) for item in data["outputs"]]
+ self.document_controller.replace_active_text_definition(
+ inputs, outputs, data["source"]
+ )
+ except (TypeError, ValueError, json.JSONDecodeError) as error:
+ QMessageBox.critical(self, "Invalid text component JSON", str(error))
+ return False
+ self._load_source_json()
+ return True
+
+ def _maybe_save(self) -> bool:
+ if self.document_controller.document is None:
+ return True
+ if self.document_controller.undo_stack.isClean():
+ return True
+ answer = QMessageBox.warning(
+ self,
+ "Unsaved changes",
+ "The current graph contains unsaved changes.",
+ QMessageBox.StandardButton.Save
+ | QMessageBox.StandardButton.Discard
+ | QMessageBox.StandardButton.Cancel,
+ )
+ if answer == QMessageBox.StandardButton.Save:
+ return self.save_document()
+ return answer == QMessageBox.StandardButton.Discard
+
+ @Slot()
+ def new_document(self) -> None:
+ if self._resolve_source_edits() and self._maybe_save():
+ self.document_controller.new_document()
+
+ @Slot()
+ def close_document(self) -> None:
+ if self._resolve_source_edits() and self._maybe_save():
+ self.document_controller.close_document()
+
+ @Slot()
+ def open_document(self) -> None:
+ if not self._resolve_source_edits() or not self._maybe_save():
+ return
+ filename, _ = QFileDialog.getOpenFileName(
+ self, "Open graph", "", "BEdit graphs (*.bedit.json *.json);;All files (*)"
+ )
+ if not filename:
+ return
+ try:
+ self.document_controller.load(Path(filename))
+ except (OSError, ValueError) as error:
+ QMessageBox.critical(self, "Could not open graph", str(error))
+
+ @Slot()
+ def save_document(self) -> bool:
+ if self.document_controller.document is None:
+ return False
+ if self.document_controller.file_path is None:
+ return self.save_document_as()
+ try:
+ self.document_controller.save()
+ except OSError as error:
+ QMessageBox.critical(self, "Could not save graph", str(error))
+ return False
+ return True
+
+ @Slot()
+ def save_document_as(self) -> bool:
+ if self.document_controller.document is None:
+ return False
+ filename, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save graph",
+ "untitled.bedit.json",
+ "BEdit graphs (*.bedit.json);;JSON files (*.json);;All files (*)",
+ )
+ if not filename:
+ return False
+ try:
+ self.document_controller.save(Path(filename))
+ except OSError as error:
+ QMessageBox.critical(self, "Could not save graph", str(error))
+ return False
+ return True
+
@Slot()
def show_settings(self) -> None:
- SettingsDialog(self).exec()
+ dialog = SettingsDialog(self)
+ dialog.settingsChanged.connect(self.reload_libraries)
+ dialog.exec()
+
+ def _document_opened_changed(self, opened: bool) -> None:
+ for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs):
+ action.setEnabled(opened)
+ self._active_graph_changed()
+
+ @Slot(object)
+ def activate_tree_component(self, index) -> None:
+ if index.data(ITEM_KIND_ROLE) != "current-component":
+ return
+ component_id = index.data(COMPONENT_ID_ROLE)
+ if component_id:
+ self.document_controller.activate_component(component_id)
+
+ @Slot(object)
+ def show_library_context_menu(self, position) -> None:
+ tree_view = self.ui.documentTreeView
+ index = tree_view.indexAt(position)
+ kind = index.data(ITEM_KIND_ROLE)
+ if kind == "current-component":
+ component_id = index.data(COMPONENT_ID_ROLE)
+ document = self.document_controller.document
+ component = document.find_component(component_id) if document else None
+ if component is None:
+ return
+ menu = QMenu(self)
+ graph_action = None
+ text_action = None
+ if component.implementation_kind == "graph":
+ graph_action = menu.addAction("New Graph Block")
+ text_action = menu.addAction("New Text Block")
+ menu.addSeparator()
+ options_action = menu.addAction("Component Options…")
+ delete_action = menu.addAction("Delete")
+ selected = menu.exec(tree_view.viewport().mapToGlobal(position))
+ if selected is graph_action:
+ self.document_controller.add_child(component_id, "graph")
+ elif selected is text_action:
+ self.document_controller.add_child(component_id, "text")
+ elif selected is options_action:
+ self.show_component_options(component_id)
+ elif selected is delete_action:
+ answer = QMessageBox.question(
+ self,
+ "Delete component?",
+ f"Delete {component.name!r} and all of its contents?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if answer == QMessageBox.StandardButton.Yes:
+ self.document_controller.delete_component(component_id)
+ return
+ if kind != "current-document":
+ return
+ menu = QMenu(self)
+ graph_action = menu.addAction("New Graph Block")
+ text_action = menu.addAction("New Text Block")
+ selected = menu.exec(tree_view.viewport().mapToGlobal(position))
+ if selected is graph_action:
+ self.document_controller.add_root("graph")
+ elif selected is text_action:
+ self.document_controller.add_root("text")
+
+ @Slot(str)
+ def show_component_options(self, component_id: str) -> None:
+ document = self.document_controller.document
+ component = document.find_component(component_id) if document else None
+ if component is None:
+ return
+ dialog = ComponentOptionsDialog(component, self)
+ if dialog.exec() == dialog.DialogCode.Accepted:
+ self.document_controller.edit_component_appearance(
+ component_id,
+ dialog.ui.nameEdit.text().strip(),
+ dialog.ui.shapeCombo.currentText(),
+ dialog.ui.fillEdit.text(),
+ dialog.ui.borderEdit.text(),
+ dialog.ui.iconTextEdit.text(),
+ dialog.ui.showSubtreeCheckBox.isChecked(),
+ )
+
+ @Slot(str, str)
+ def show_port_options(self, port_id: str, direction: str) -> None:
+ owner = self.document_controller.active_component
+ if owner is None:
+ return
+ ports = owner.inputs if direction == "input" else owner.outputs
+ port = next((item for item in ports if item.id == port_id), None)
+ if port is None:
+ return
+ dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self)
+ if dialog.exec() == dialog.DialogCode.Accepted:
+ self.document_controller.rename_interface_port(port_id, dialog.name)
+
+ @Slot(str)
+ def show_connection_options(self, connection_id: str) -> None:
+ owner = self.document_controller.active_component
+ if owner is None or owner.implementation_kind != "graph":
+ return
+ connection = owner.graph.connections.get(connection_id)
+ if connection is None:
+ return
+ dialog = ItemOptionsDialog(
+ "Connection Options", connection.name, self, name_required=False
+ )
+ if dialog.exec() == dialog.DialogCode.Accepted:
+ self.document_controller.rename_connection(connection_id, dialog.name)
@Slot()
def show_about(self) -> None:
QMessageBox.about(
self,
"About BEdit",
- "BEdit
A starter desktop application built with Python and Qt.
",
+ "BEdit
A graphical editor built with Python and Qt.
",
)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name)
+ if not self._resolve_source_edits() or not self._maybe_save():
+ event.ignore()
+ return
self.settings.setValue("window/geometry", self.saveGeometry())
event.accept()
diff --git a/BEdit/src/bedit/resources_rc.py b/BEdit/src/bedit/resources_rc.py
index f652626..915731e 100644
--- a/BEdit/src/bedit/resources_rc.py
+++ b/BEdit/src/bedit/resources_rc.py
@@ -234,6 +234,114 @@ _H\xaed[3B\xe2\xfa3\xb9\x04\xc0\xec\xee\xee\
\xe2\xbf\x0f\x0f\x0f\xa7)sR\xf6\xffA\x01\x8e9D\
R\xff\x03\x8c\xc5\xeaCX+lK\x00\x00\x00\x00I\
END\xaeB`\x82\
+\x00\x00\x06\x9c\
+\x89\
+PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
+\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\
+\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\
+\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\
+\x00\x00\x09pHYs\x00\x00\x06\xec\x00\x00\x06\xec\x01\
+\x1eu85\x00\x00\x00\x07tIME\x07\xd9\x06\x11\
+\x17\x13$\xda\x852\xab\x00\x00\x06\x1cIDATx\
+\xda\xed\x95}\x8c\x15\xd5\x19\xc6\x9f\xf7\xcc\xdc;\xb3w\
+?\xfd\xa0T\xd8\x22+$(\xf2\xd5j\xc2\xae\x1f$\
+\xadI\xff\xd0\xb8J(1%\x9a\x08%\xd6\x15-\xb6\
+\x8d\xb6\xb1]H\x0bX\xa2\xaeUB\xac\xa1\xa9\xd1H\
+\x1b\x14\xa4F\xa0\xc8*\x9a\x0a\xbaT\x5cL\x88\xc9\x92\
+ \x88\xd4\x82Z\xe1\xde;w\xef\x9d\xef\xf3\xf6\xcc\xb9\
+\xc3d7J\x1a\xdc\xb4\x7f\xf1K\xde\xfb\xce\x99\xb9\x93\
+\xe79\xef{\xce\x19\x9c\xe7=\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\x8f\xd5\x96\x09\xb0\xf0\xca\x12j~\x8c0\
+Tf\xd2\x08\x93\x88\x22%\x18kQ\xc9\x5c\x9f\xbd\xde\
+\xc2\x89p\xfd@\xcb+\x03\xcf\x0dH\x0c{\xe8\xfb\xe0\
+dxb\xe6\xca\xf2\xec\x92';\x9c\x9a<\x06\xc5\xf7\
+\xfb\x9c\x1cA\xd1\xb9\xc6\x81iRO\xd5\x8d\xd6\x1fX\
+\xd9&\xfe\xb4\xc7\xc7\xde\xc3\x11&4\xd6\xb0hv\x05\
+L\x02\x86\xde\x92\x86\xfe\x10\x09C\x8dI\x8d\xd3kS\
+\xdd'\x91\xe4\xfaX\x90\xd0\xff+\xba\xc4K\x9e\x8f\xbd\
+]\xf77\x16&\xff\xc2yN@\xde\xa1\x1e\x07\xea\xf9\
+6\xd3\xc4\x1c\x83\xd8\x17P\xec\xfbUK\xb2\x0e\x9a/\
+n\xe4\x00\x8aE\x9dy\x1c\xfc$\xc6m3\x87!\x91\
+\x8a\x89D\x10Z\x88\xce\x88\x8b\xfa3\xaaW\xba~_\
+\x9b%4X\x02\x0f\xef\x94Dd,\x81\xc2\xf1\xc5\xbd\
+\x8e\x07\xaax\x9cW-\xf8A\xa5&O\x1e|\xa70\
+C \xa5\xb9A\xb8\x0dy\xc3\x84\xe2\xc1\xcd\x9e\x9ay\
+U\x09@\x8b\x90\xa1\x82(5\xa2\xb2\x00H\x0b\xd7\xc7\
+\x02i\x85\xf4\xfd\xfa;{\x8f0\xef\xff8\x1ezu\
+\xb9\xbd\x09\x8a\xd3O4U\xaa>\xd69.h8\xa0\
+U\xc7\x1fk\x9b7q\x96\xd2@J\x8b-\x06\x0b\x0d\
+9\x13`\xde\xfb\xa1\xc45\x93\x036\xcdd\x86\x80H\
+\x221\x91\x1d\xcf\xa9\x11\x8c0\x92\x1d\xdb\x02\xb6\x05\xf4\
+\xbe\x1c\x12Ic\xe1u\x8f8Hh\xb8\xbb\x08\x90x\
+\x88\xc9\xe8v\xd6\xb7\xae\x80\xe2_}m \x8c\xe0\xfe\
+M._H\x9f\x1f\xbf\x98>s~\x7f`\xca\x8c\x9b\
+\xa7\xc7Xv\xbd\x0fF:S#\xc9F\xd6\x7f\xf3\x8c\
+\x11#\x19\x1b\xba\xfcy\x83\xf0\xcc@\x8c'^\x8f^\
+<\xb4\xa6\xe56\xd4\xc9L\xb8O_p\xf6\x8fQ\xc1\
+6\xef\x1d6\xc7O:\x84\xab\xfe|x\xedE\xb4\xf1\
+}\xabw\xde\xfaf\xec:\x94C\xb3\x9d\xf6W\x97\x18\
+i\xefU\xd4\x17]Z% \x90\x845;B\x84\xd2\
+X<\xf3\x97%\x8c \x15\x1f\x0d\x01\xa3Y\xbd=\xf8\
+\xa3\x04\x96J)\xff!\x88~\xb3b\xbb\xf3\xf7\x82\xc8\
+={\xd98^\xb8nA\xccs\xda\x89B\xd6\x0b\xad\
+.\xac\x0f\xacz\xf9\xd5\x96\xc6\x8f7\x86\xd8vP\xf6\
+\x9e\xeck^\x8dse\xedN_\xe7\xbe\xd7\xa3\xee\xc7\
+\xfa\x83\xc1Gv\x05\xbc\xe2\xafANW\xe7\x9e\xf2\x0c\
+,.\x0du?Y\xe4\xe3'K\xb2\x5cv\xb8\xe8\x0c\
+se\xb8\xca\xd5\x9a\xc7\x9e\x17\xf0\xd0'\xbe4\x96\x16\
+K\x18+\x8f\xf7\xd7\x8d<\xbdGN\xca\xda\xd3S\xd4\
+\xd9\xee\xa9\xfc\x10?*\xd5\x1e\xda\xe2p\xe0)q\xd7\
+\xe5\x9a\xe73s\xc8\xb3W:l\xf7\x94\xbb\xf1\x17\xc6\
+\xff\x1c\xf3.\xe7wm\xf7\x95x\xf3\xbbU\x99\x88o\
+\xd9\xefJ,)\x0e\xe2\x1c!\x8c\x81\x5cO\xe9\x82\xd0\
+\xe3\x8dWw\x187\x1e\xfdB\xc2\x8d1\x15\xa1<\xe2\
+n\xb8\x10\xff\x17\x1a\x96\x9e\xd2\xd9Z\xe6L-\xdc\xe3\
+\x8c\xc7y\xbe\x06\xff\x01\xd8\x02\xdah\xfa;i$\x00\
+\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x07\xe4\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -906,6 +1014,11 @@ qt_resource_name = b"\
\x00d\
\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\
\x00p\x00n\x00g\
+\x00\x14\
+\x0c\xbb2g\
+\x00t\
+\x00r\x00a\x00n\x00s\x00f\x00o\x00r\x00m\x00-\x00r\x00o\x00t\x00a\x00t\x00e\x00.\
+\x00p\x00n\x00g\
\x00\x0d\
\x03\xd2\xbeg\
\x00e\
@@ -943,25 +1056,27 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\x00\x00\x02\x00\x00\x00\x09\x00\x00\x00\x03\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0a\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00\x19Z\
+\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xfa\
\x00\x00\x01\x9f{C\xf1'\
-\x00\x00\x00\xe2\x00\x00\x00\x00\x00\x01\x00\x00$\x8c\
+\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00+,\
\x00\x00\x01\x9f{0\xc99\
-\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\
+\x00\x00\x00\x92\x00\x00\x00\x00\x00\x01\x00\x00\x14\x92\
\x00\x00\x01\x9f{C\xf1\x18\
-\x00\x00\x00\x84\x00\x00\x00\x00\x00\x01\x00\x00\x15\xda\
+\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x1cz\
\x00\x00\x01\x9f{C\xf1.\
\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\
\x00\x00\x01\x9f{0\xc9B\
-\x00\x00\x01\x0a\x00\x00\x00\x00\x00\x01\x00\x00+\x96\
+\x00\x00\x018\x00\x00\x00\x00\x00\x01\x00\x0026\
\x00\x00\x01\x9f{C\xf1N\
+\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\
+\x00\x00\x01\x9f{\x8d\xf34\
\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9f{0\xc9+\
-\x00\x00\x00\xc2\x00\x00\x00\x00\x00\x01\x00\x00\x1c\xba\
+\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00#Z\
\x00\x00\x01\x9f{C\xf1=\
-\x00\x00\x01,\x00\x00\x00\x00\x00\x01\x00\x001\x18\
+\x00\x00\x01Z\x00\x00\x00\x00\x00\x01\x00\x007\xb8\
\x00\x00\x01\x9f{0\xc9R\
"
diff --git a/BEdit/src/bedit/settings_dialog.py b/BEdit/src/bedit/settings_dialog.py
index f557fc7..82f5c3f 100644
--- a/BEdit/src/bedit/settings_dialog.py
+++ b/BEdit/src/bedit/settings_dialog.py
@@ -1,17 +1,26 @@
-from PySide6.QtCore import QSettings
-from PySide6.QtWidgets import QDialog
+from pathlib import Path
+from PySide6.QtCore import QSettings, Signal
+from PySide6.QtWidgets import QDialog, QFileDialog
+
+from bedit.library.repository import default_library_paths
from bedit.ui_settings_dialog import Ui_SettingsDialog
class SettingsDialog(QDialog):
"""Edit application preferences defined in the Designer form."""
+ settingsChanged = Signal()
+
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_SettingsDialog()
self.ui.setupUi(self)
self.settings = QSettings()
+ self.ui.addLibraryFileButton.clicked.connect(self._add_library_file)
+ self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder)
+ self.ui.removeLibraryPathButton.clicked.connect(self._remove_library_path)
+ self.ui.libraryPathsList.itemSelectionChanged.connect(self._update_remove_button)
self._load_settings()
def _load_settings(self) -> None:
@@ -21,10 +30,59 @@ class SettingsDialog(QDialog):
self.ui.autosaveIntervalSpinBox.setValue(
self.settings.value("general/autosaveInterval", 5, type=int)
)
+ self.ui.libraryPathsList.clear()
+ self.ui.libraryPathsList.addItems(self.library_paths(self.settings))
+ self._update_remove_button()
+
+ @staticmethod
+ def library_paths(settings: QSettings | None = None) -> list[str]:
+ settings = settings or QSettings()
+ value = settings.value("libraries/paths", default_library_paths())
+ if isinstance(value, str):
+ return [value]
+ return [str(path) for path in value]
+
+ def _add_library_file(self) -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Add library",
+ "",
+ "BEdit libraries (*.json);;All files (*)",
+ )
+ if path:
+ self._append_unique_path(path)
+
+ def _add_library_folder(self) -> None:
+ path = QFileDialog.getExistingDirectory(self, "Add library folder")
+ if path:
+ self._append_unique_path(path)
+
+ def _append_unique_path(self, path: str) -> None:
+ normalized = str(Path(path).expanduser().resolve())
+ existing = {
+ self.ui.libraryPathsList.item(row).text()
+ for row in range(self.ui.libraryPathsList.count())
+ }
+ if normalized not in existing:
+ self.ui.libraryPathsList.addItem(normalized)
+
+ def _remove_library_path(self) -> None:
+ for item in self.ui.libraryPathsList.selectedItems():
+ self.ui.libraryPathsList.takeItem(self.ui.libraryPathsList.row(item))
+
+ def _update_remove_button(self) -> None:
+ self.ui.removeLibraryPathButton.setEnabled(bool(self.ui.libraryPathsList.selectedItems()))
def accept(self) -> None:
self.settings.setValue("general/autosaveEnabled", self.ui.autosaveGroupBox.isChecked())
self.settings.setValue(
"general/autosaveInterval", self.ui.autosaveIntervalSpinBox.value()
)
+ paths = [
+ self.ui.libraryPathsList.item(row).text()
+ for row in range(self.ui.libraryPathsList.count())
+ ]
+ self.settings.setValue("libraries/paths", paths)
+ self.settings.sync()
+ self.settingsChanged.emit()
super().accept()
diff --git a/BEdit/src/bedit/ui_component_options_dialog.py b/BEdit/src/bedit/ui_component_options_dialog.py
new file mode 100644
index 0000000..f415f8d
--- /dev/null
+++ b/BEdit/src/bedit/ui_component_options_dialog.py
@@ -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
+
diff --git a/BEdit/src/bedit/ui_main_window.py b/BEdit/src/bedit/ui_main_window.py
index e41f54e..1f447f3 100644
--- a/BEdit/src/bedit/ui_main_window.py
+++ b/BEdit/src/bedit/ui_main_window.py
@@ -16,10 +16,13 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
-from PySide6.QtWidgets import (QApplication, QDockWidget, QHBoxLayout, QHeaderView,
- QMainWindow, QMenu, QMenuBar, QSizePolicy,
- QSplitter, QToolBar, QTreeView, QVBoxLayout,
- QWidget)
+from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout,
+ QHeaderView, QLabel, QMainWindow, QMenu,
+ QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy,
+ QSpacerItem, QSplitter, QStackedWidget, QToolBar,
+ QToolButton, QTreeView, QVBoxLayout, QWidget)
+
+from bedit.workspace.view import GraphWorkspaceView
from . import resources_rc
class Ui_MainWindow(object):
@@ -32,50 +35,59 @@ class Ui_MainWindow(object):
icon = QIcon()
icon.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon)
+ self.actionRotateClockwise = QAction(MainWindow)
+ self.actionRotateClockwise.setObjectName(u"actionRotateClockwise")
+ icon1 = QIcon()
+ icon1.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionRotateClockwise.setIcon(icon1)
self.actionOpen = QAction(MainWindow)
self.actionOpen.setObjectName(u"actionOpen")
- icon1 = QIcon()
- icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionOpen.setIcon(icon1)
+ icon2 = QIcon()
+ icon2.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionOpen.setIcon(icon2)
self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave")
- icon2 = QIcon()
- icon2.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionSave.setIcon(icon2)
+ icon3 = QIcon()
+ icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionSave.setIcon(icon3)
self.actionSaveAs = QAction(MainWindow)
self.actionSaveAs.setObjectName(u"actionSaveAs")
- icon3 = QIcon()
- icon3.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionSaveAs.setIcon(icon3)
+ icon4 = QIcon()
+ icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionSaveAs.setIcon(icon4)
self.actionExit = QAction(MainWindow)
self.actionExit.setObjectName(u"actionExit")
+ self.actionClose = QAction(MainWindow)
+ self.actionClose.setObjectName(u"actionClose")
self.actionUndo = QAction(MainWindow)
self.actionUndo.setObjectName(u"actionUndo")
- icon4 = QIcon()
- icon4.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionUndo.setIcon(icon4)
+ icon5 = QIcon()
+ icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionUndo.setIcon(icon5)
self.actionRedo = QAction(MainWindow)
self.actionRedo.setObjectName(u"actionRedo")
- icon5 = QIcon()
- icon5.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionRedo.setIcon(icon5)
+ icon6 = QIcon()
+ icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionRedo.setIcon(icon6)
self.actionCut = QAction(MainWindow)
self.actionCut.setObjectName(u"actionCut")
- icon6 = QIcon()
- icon6.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionCut.setIcon(icon6)
+ icon7 = QIcon()
+ icon7.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionCut.setIcon(icon7)
self.actionCopy = QAction(MainWindow)
self.actionCopy.setObjectName(u"actionCopy")
- icon7 = QIcon()
- icon7.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionCopy.setIcon(icon7)
+ icon8 = QIcon()
+ icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionCopy.setIcon(icon8)
self.actionPaste = QAction(MainWindow)
self.actionPaste.setObjectName(u"actionPaste")
- icon8 = QIcon()
- icon8.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
- self.actionPaste.setIcon(icon8)
+ icon9 = QIcon()
+ icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionPaste.setIcon(icon9)
self.actionSelectAll = QAction(MainWindow)
self.actionSelectAll.setObjectName(u"actionSelectAll")
+ self.actionDelete = QAction(MainWindow)
+ self.actionDelete.setObjectName(u"actionDelete")
self.actionAbout = QAction(MainWindow)
self.actionAbout.setObjectName(u"actionAbout")
self.actionSettings = QAction(MainWindow)
@@ -118,6 +130,24 @@ class Ui_MainWindow(object):
self.panel_libraries.setWidget(self.dockWidgetContents)
self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_libraries)
+ self.panel_document = QDockWidget(self.leftDockHost)
+ self.panel_document.setObjectName(u"panel_document")
+ self.panel_document.setMinimumSize(QSize(220, 91))
+ self.documentDockContents = QWidget()
+ self.documentDockContents.setObjectName(u"documentDockContents")
+ self.documentPanelLayout = QVBoxLayout(self.documentDockContents)
+ self.documentPanelLayout.setSpacing(0)
+ self.documentPanelLayout.setObjectName(u"documentPanelLayout")
+ self.documentPanelLayout.setContentsMargins(0, 0, 0, 0)
+ self.documentTreeView = QTreeView(self.documentDockContents)
+ self.documentTreeView.setObjectName(u"documentTreeView")
+ self.documentTreeView.setAlternatingRowColors(True)
+ self.documentTreeView.setUniformRowHeights(True)
+
+ self.documentPanelLayout.addWidget(self.documentTreeView)
+
+ self.panel_document.setWidget(self.documentDockContents)
+ self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_document)
self.workspaceSplitter.addWidget(self.leftDockHost)
self.workspace = QWidget(self.workspaceSplitter)
self.workspace.setObjectName(u"workspace")
@@ -126,9 +156,107 @@ class Ui_MainWindow(object):
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.workspace.sizePolicy().hasHeightForWidth())
self.workspace.setSizePolicy(sizePolicy1)
- self.workspace.setStyleSheet(u"QWidget#workspace {\n"
-" background-color: rgb(198, 198, 198);\n"
-"}")
+ self.workspaceEditorLayout = QVBoxLayout(self.workspace)
+ self.workspaceEditorLayout.setSpacing(0)
+ self.workspaceEditorLayout.setObjectName(u"workspaceEditorLayout")
+ self.workspaceEditorLayout.setContentsMargins(0, 0, 0, 0)
+ self.workspaceHeader = QFrame(self.workspace)
+ self.workspaceHeader.setObjectName(u"workspaceHeader")
+ self.workspaceHeader.setMinimumSize(QSize(0, 34))
+ self.workspaceHeader.setMaximumSize(QSize(16777215, 34))
+ self.workspaceHeader.setFrameShape(QFrame.Shape.StyledPanel)
+ self.workspaceHeaderLayout = QHBoxLayout(self.workspaceHeader)
+ self.workspaceHeaderLayout.setObjectName(u"workspaceHeaderLayout")
+ self.workspaceHeaderLayout.setContentsMargins(6, 2, 6, 2)
+ self.navigateUpButton = QToolButton(self.workspaceHeader)
+ self.navigateUpButton.setObjectName(u"navigateUpButton")
+
+ self.workspaceHeaderLayout.addWidget(self.navigateUpButton)
+
+ self.graphBreadcrumbLabel = QLabel(self.workspaceHeader)
+ self.graphBreadcrumbLabel.setObjectName(u"graphBreadcrumbLabel")
+
+ self.workspaceHeaderLayout.addWidget(self.graphBreadcrumbLabel)
+
+ self.workspaceModeLabel = QLabel(self.workspaceHeader)
+ self.workspaceModeLabel.setObjectName(u"workspaceModeLabel")
+
+ self.workspaceHeaderLayout.addWidget(self.workspaceModeLabel)
+
+ self.workspaceHeaderSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
+
+ self.workspaceHeaderLayout.addItem(self.workspaceHeaderSpacer)
+
+ self.applyJsonButton = QPushButton(self.workspaceHeader)
+ self.applyJsonButton.setObjectName(u"applyJsonButton")
+ self.applyJsonButton.setVisible(False)
+
+ self.workspaceHeaderLayout.addWidget(self.applyJsonButton)
+
+ self.pointerToolButton = QToolButton(self.workspaceHeader)
+ self.pointerToolButton.setObjectName(u"pointerToolButton")
+ self.pointerToolButton.setCheckable(True)
+ self.pointerToolButton.setChecked(True)
+ self.pointerToolButton.setAutoExclusive(True)
+
+ self.workspaceHeaderLayout.addWidget(self.pointerToolButton)
+
+ self.inputToolButton = QToolButton(self.workspaceHeader)
+ self.inputToolButton.setObjectName(u"inputToolButton")
+ self.inputToolButton.setCheckable(True)
+ self.inputToolButton.setAutoExclusive(True)
+
+ self.workspaceHeaderLayout.addWidget(self.inputToolButton)
+
+ self.outputToolButton = QToolButton(self.workspaceHeader)
+ self.outputToolButton.setObjectName(u"outputToolButton")
+ self.outputToolButton.setCheckable(True)
+ self.outputToolButton.setAutoExclusive(True)
+
+ self.workspaceHeaderLayout.addWidget(self.outputToolButton)
+
+
+ self.workspaceEditorLayout.addWidget(self.workspaceHeader)
+
+ self.workspaceStack = QStackedWidget(self.workspace)
+ self.workspaceStack.setObjectName(u"workspaceStack")
+ self.graphPage = QWidget()
+ self.graphPage.setObjectName(u"graphPage")
+ self.graphPageLayout = QVBoxLayout(self.graphPage)
+ self.graphPageLayout.setObjectName(u"graphPageLayout")
+ self.graphPageLayout.setContentsMargins(0, 0, 0, 0)
+ self.graphView = GraphWorkspaceView(self.graphPage)
+ self.graphView.setObjectName(u"graphView")
+
+ self.graphPageLayout.addWidget(self.graphView)
+
+ self.workspaceStack.addWidget(self.graphPage)
+ self.jsonPage = QWidget()
+ self.jsonPage.setObjectName(u"jsonPage")
+ self.jsonPageLayout = QVBoxLayout(self.jsonPage)
+ self.jsonPageLayout.setObjectName(u"jsonPageLayout")
+ self.jsonPageLayout.setContentsMargins(0, 0, 0, 0)
+ self.jsonEditor = QPlainTextEdit(self.jsonPage)
+ self.jsonEditor.setObjectName(u"jsonEditor")
+ self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
+
+ self.jsonPageLayout.addWidget(self.jsonEditor)
+
+ self.workspaceStack.addWidget(self.jsonPage)
+ self.emptyPage = QWidget()
+ self.emptyPage.setObjectName(u"emptyPage")
+ self.emptyPageLayout = QVBoxLayout(self.emptyPage)
+ self.emptyPageLayout.setObjectName(u"emptyPageLayout")
+ self.emptyWorkspaceLabel = QLabel(self.emptyPage)
+ self.emptyWorkspaceLabel.setObjectName(u"emptyWorkspaceLabel")
+ self.emptyWorkspaceLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ self.emptyPageLayout.addWidget(self.emptyWorkspaceLabel)
+
+ self.workspaceStack.addWidget(self.emptyPage)
+
+ self.workspaceEditorLayout.addWidget(self.workspaceStack)
+
self.workspaceSplitter.addWidget(self.workspace)
self.workspaceLayout.addWidget(self.workspaceSplitter)
@@ -164,6 +292,9 @@ class Ui_MainWindow(object):
self.editToolbar = QToolBar(MainWindow)
self.editToolbar.setObjectName(u"editToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.editToolbar)
+ self.transformToolbar = QToolBar(MainWindow)
+ self.transformToolbar.setObjectName(u"transformToolbar")
+ MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.transformToolbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction())
@@ -175,6 +306,7 @@ class Ui_MainWindow(object):
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs)
self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionClose)
self.menuFile.addAction(self.actionExit)
self.menuEdit.addAction(self.actionUndo)
self.menuEdit.addAction(self.actionRedo)
@@ -182,6 +314,8 @@ class Ui_MainWindow(object):
self.menuEdit.addAction(self.actionCopy)
self.menuEdit.addAction(self.actionCut)
self.menuEdit.addAction(self.actionPaste)
+ self.menuEdit.addAction(self.actionDelete)
+ self.menuEdit.addAction(self.actionSelectAll)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionSettings)
self.menuView.addAction(self.menuPanels.menuAction())
@@ -197,9 +331,13 @@ class Ui_MainWindow(object):
self.editToolbar.addAction(self.actionCopy)
self.editToolbar.addAction(self.actionCut)
self.editToolbar.addAction(self.actionPaste)
+ self.transformToolbar.addAction(self.actionRotateClockwise)
self.retranslateUi(MainWindow)
+ self.workspaceStack.setCurrentIndex(0)
+
+
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
@@ -211,6 +349,13 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionNew.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionRotateClockwise.setText(QCoreApplication.translate("MainWindow", u"Rotate Clockwise", None))
+#if QT_CONFIG(tooltip)
+ self.actionRotateClockwise.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise by 90 degrees", None))
+#endif // QT_CONFIG(tooltip)
+#if QT_CONFIG(shortcut)
+ self.actionRotateClockwise.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+R", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None))
#if QT_CONFIG(statustip)
@@ -233,6 +378,10 @@ class Ui_MainWindow(object):
self.actionExit.setText(QCoreApplication.translate("MainWindow", u"E&xit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionClose.setText(QCoreApplication.translate("MainWindow", u"&Close Document", None))
+#if QT_CONFIG(shortcut)
+ self.actionClose.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+W", None))
#endif // QT_CONFIG(shortcut)
self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"&Undo", None))
#if QT_CONFIG(shortcut)
@@ -257,6 +406,10 @@ class Ui_MainWindow(object):
self.actionSelectAll.setText(QCoreApplication.translate("MainWindow", u"Select &All", None))
#if QT_CONFIG(shortcut)
self.actionSelectAll.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+A", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionDelete.setText(QCoreApplication.translate("MainWindow", u"&Delete", None))
+#if QT_CONFIG(shortcut)
+ self.actionDelete.setShortcut(QCoreApplication.translate("MainWindow", u"Del", None))
#endif // QT_CONFIG(shortcut)
self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"&About BEdit", None))
self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"&Settings\u2026", None))
@@ -265,6 +418,25 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip)
self.actionAboutQt.setText(QCoreApplication.translate("MainWindow", u"About &Qt", None))
self.panel_libraries.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", None))
+ self.panel_document.setWindowTitle(QCoreApplication.translate("MainWindow", u"Document", None))
+ self.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None))
+#if QT_CONFIG(tooltip)
+ self.navigateUpButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the containing graph", None))
+#endif // QT_CONFIG(tooltip)
+ self.graphBreadcrumbLabel.setText(QCoreApplication.translate("MainWindow", u"Untitled", None))
+ self.workspaceModeLabel.setText(QCoreApplication.translate("MainWindow", u"Graph", None))
+ self.applyJsonButton.setText(QCoreApplication.translate("MainWindow", u"Apply JSON", None))
+ self.pointerToolButton.setText(QCoreApplication.translate("MainWindow", u"Pointer", None))
+ self.inputToolButton.setText(QCoreApplication.translate("MainWindow", u"Input", None))
+#if QT_CONFIG(tooltip)
+ self.inputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface input", None))
+#endif // QT_CONFIG(tooltip)
+ self.outputToolButton.setText(QCoreApplication.translate("MainWindow", u"Output", None))
+#if QT_CONFIG(tooltip)
+ self.outputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface output", None))
+#endif // QT_CONFIG(tooltip)
+ self.jsonEditor.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Component JSON", None))
+ self.emptyWorkspaceLabel.setText(QCoreApplication.translate("MainWindow", u"No document open", None))
self.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None))
self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None))
self.menuView.setTitle(QCoreApplication.translate("MainWindow", u"&View", None))
@@ -273,5 +445,6 @@ class Ui_MainWindow(object):
self.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"&Help", None))
self.fileToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"File", None))
self.editToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Edit", None))
+ self.transformToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Transform", None))
# retranslateUi
diff --git a/BEdit/src/bedit/ui_settings_dialog.py b/BEdit/src/bedit/ui_settings_dialog.py
index 488ee94..05a3dbd 100644
--- a/BEdit/src/bedit/ui_settings_dialog.py
+++ b/BEdit/src/bedit/ui_settings_dialog.py
@@ -16,8 +16,10 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox,
- QFormLayout, QGroupBox, QSizePolicy, QSpacerItem,
- QSpinBox, QTabWidget, QVBoxLayout, QWidget)
+ QFormLayout, QGroupBox, QHBoxLayout, QLabel,
+ QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
+ QSpacerItem, QSpinBox, QTabWidget, QVBoxLayout,
+ QWidget)
class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog):
@@ -55,6 +57,46 @@ class Ui_SettingsDialog(object):
self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "")
+ self.librariesTab = QWidget()
+ self.librariesTab.setObjectName(u"librariesTab")
+ self.librariesTabLayout = QVBoxLayout(self.librariesTab)
+ self.librariesTabLayout.setObjectName(u"librariesTabLayout")
+ self.libraryPathsLabel = QLabel(self.librariesTab)
+ self.libraryPathsLabel.setObjectName(u"libraryPathsLabel")
+ self.libraryPathsLabel.setWordWrap(True)
+
+ self.librariesTabLayout.addWidget(self.libraryPathsLabel)
+
+ self.libraryPathsList = QListWidget(self.librariesTab)
+ self.libraryPathsList.setObjectName(u"libraryPathsList")
+
+ self.librariesTabLayout.addWidget(self.libraryPathsList)
+
+ self.libraryPathButtonsLayout = QHBoxLayout()
+ self.libraryPathButtonsLayout.setObjectName(u"libraryPathButtonsLayout")
+ self.addLibraryFileButton = QPushButton(self.librariesTab)
+ self.addLibraryFileButton.setObjectName(u"addLibraryFileButton")
+
+ self.libraryPathButtonsLayout.addWidget(self.addLibraryFileButton)
+
+ self.addLibraryFolderButton = QPushButton(self.librariesTab)
+ self.addLibraryFolderButton.setObjectName(u"addLibraryFolderButton")
+
+ self.libraryPathButtonsLayout.addWidget(self.addLibraryFolderButton)
+
+ self.removeLibraryPathButton = QPushButton(self.librariesTab)
+ self.removeLibraryPathButton.setObjectName(u"removeLibraryPathButton")
+
+ self.libraryPathButtonsLayout.addWidget(self.removeLibraryPathButton)
+
+ self.libraryButtonsSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
+
+ self.libraryPathButtonsLayout.addItem(self.libraryButtonsSpacer)
+
+
+ self.librariesTabLayout.addLayout(self.libraryPathButtonsLayout)
+
+ self.settingsTabs.addTab(self.librariesTab, "")
self.dialogLayout.addWidget(self.settingsTabs)
@@ -81,5 +123,10 @@ class Ui_SettingsDialog(object):
self.autosaveGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Automatic saving", None))
self.autosaveIntervalSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" minutes", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None))
+ self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON files from these files or folders at startup:", None))
+ self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None))
+ self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None))
+ self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None))
+ self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.librariesTab), QCoreApplication.translate("SettingsDialog", u"Libraries", None))
# retranslateUi
diff --git a/BEdit/src/bedit/workspace/__init__.py b/BEdit/src/bedit/workspace/__init__.py
new file mode 100644
index 0000000..72dae2f
--- /dev/null
+++ b/BEdit/src/bedit/workspace/__init__.py
@@ -0,0 +1,4 @@
+from bedit.workspace.view import GraphWorkspaceView
+
+__all__ = ["GraphWorkspaceView"]
+
diff --git a/BEdit/src/bedit/workspace/view.py b/BEdit/src/bedit/workspace/view.py
new file mode 100644
index 0000000..53b8d0b
--- /dev/null
+++ b/BEdit/src/bedit/workspace/view.py
@@ -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()
diff --git a/BEdit/ui/component_options_dialog.ui b/BEdit/ui/component_options_dialog.ui
new file mode 100644
index 0000000..e43d21b
--- /dev/null
+++ b/BEdit/ui/component_options_dialog.ui
@@ -0,0 +1,32 @@
+
+
+ ComponentOptionsDialog
+
+ 00420260
+ Component Options
+
+ -
+
+
- Name:
+
+ - Icon shape:
+ - rectangle
- ellipse
+ - Icon text:
+
+ - Fill color:
+ - #dbeafe
+ - Border color:
+ - #303030
+ - Show contained components in the Libraries treetrue
+
+
+ - Qt::Orientation::Vertical2040
+ - QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok
+
+
+
+
+ buttonBoxaccepted()ComponentOptionsDialogaccept()
+ buttonBoxrejected()ComponentOptionsDialogreject()
+
+
diff --git a/BEdit/ui/main_window.ui b/BEdit/ui/main_window.ui
index 83d38c7..36b48e3 100644
--- a/BEdit/ui/main_window.ui
+++ b/BEdit/ui/main_window.ui
@@ -94,19 +94,91 @@
+
+
+ 22091
+
+ Document
+ 1
+
+
+ 0
+ 0
+ 0
+ 0
+ 0
+ -
+
+ true
+ true
+
+
+
+
+
-
+
1
0
-
- QWidget#workspace {
- background-color: rgb(198, 198, 198);
-}
-
+
+ 0
+ 0
+ 0
+ 0
+ 0
+ -
+
+ 034
+ 1677721534
+ QFrame::Shape::StyledPanel
+
+ 6
+ 2
+ 6
+ 2
+
- UpOpen the containing graph
+ - Untitled
+ - Graph
+ - Qt::Orientation::Horizontal4020
+ - Apply JSONfalse
+ - Pointertruetruetrue
+ - InputAdd an interface inputtruetrue
+ - OutputAdd an interface outputtruetrue
+
+
+
+ -
+
+ 0
+
+
+ 0000
+
+
+
+
+
+ 0000
+ - QPlainTextEdit::LineWrapMode::NoWrapComponent JSON
+
+
+
+
+ -
+
+ No document open
+ Qt::AlignmentFlag::AlignCenter
+
+
+
+
+
+
+
@@ -131,6 +203,7 @@
+
@@ -230,6 +305,12 @@
+
+ Transform
+ TopToolBarArea
+ false
+
+
@@ -245,6 +326,15 @@
Ctrl+N
+
+
+
+ :/icons/icons/transform-rotate.png:/icons/icons/transform-rotate.png
+
+ Rotate Clockwise
+ Rotate selected blocks clockwise by 90 degrees
+ Ctrl+R
+
@@ -295,6 +385,10 @@
Ctrl+Q
+
+ &Close Document
+ Ctrl+W
+
@@ -363,6 +457,10 @@
Ctrl+A
+
+ &Delete
+ Del
+
&About BEdit
@@ -382,6 +480,13 @@
+
+
+ GraphWorkspaceView
+ QGraphicsView
+
+
+
diff --git a/BEdit/ui/settings_dialog.ui b/BEdit/ui/settings_dialog.ui
index 2329a64..9491022 100644
--- a/BEdit/ui/settings_dialog.ui
+++ b/BEdit/ui/settings_dialog.ui
@@ -73,6 +73,51 @@
+
+
+ Libraries
+
+
+ -
+
+
+ Load library JSON files from these files or folders at startup:
+
+
+ true
+
+
+
+ -
+
+
+ -
+
+
-
+
+ Add File…
+
+
+ -
+
+ Add Folder…
+
+
+ -
+
+ Remove
+
+
+ -
+
+ Qt::Orientation::Horizontal
+ 4020
+
+
+
+
+
+
-