new text mode editor

This commit is contained in:
2026-07-20 14:24:47 +02:00
parent bf2512995f
commit a067c85994
14 changed files with 685 additions and 392 deletions

View File

@@ -13,7 +13,7 @@ class LibraryDocument:
def bundled_library_path() -> Path:
return Path(__file__).resolve().parents[1] / "data" / "libraries" / "example.json"
return Path(__file__).resolve().parents[1] / "data" / "libraries" / "default.json"
def default_library_paths() -> list[str]:

View File

@@ -152,6 +152,30 @@ class Connection:
)
@dataclass
class Parameter:
id: str
name: str
type: str = "real"
value: str = "0"
def to_dict(self) -> dict[str, str]:
return {"id": self.id, "name": self.name, "type": self.type, "value": self.value}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Parameter":
if not isinstance(data, dict):
raise ValueError("Each text component parameter must be an object")
if "id" not in data:
raise ValueError("Each text component parameter must have an ID")
return cls(
id=str(data["id"]),
name=str(data.get("name", "")),
type=str(data.get("type", "real")),
value=str(data.get("value", "0")),
)
@dataclass
class Annotation:
id: str
@@ -271,6 +295,19 @@ class Component:
kind = str(implementation.get("kind", "graph"))
if kind not in {"graph", "text"}:
raise ValueError(f"Unknown component implementation kind: {kind}")
source: dict[str, Any] = {}
if kind == "text":
raw_source = implementation.get("source", {})
equations = raw_source.get("equations", "")
parameters = raw_source.get("parameters", [])
if not isinstance(equations, str):
raise ValueError("Text component equations must be a string")
if not isinstance(parameters, list):
raise ValueError("Text component parameters must be a list")
source = {
"equations": equations,
"parameters": [Parameter.from_dict(item).to_dict() for item in parameters],
}
return cls(
id=str(data["id"]),
name=str(data.get("name", "Unnamed")),
@@ -284,7 +321,7 @@ class Component:
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 {},
source=source,
)
@@ -348,6 +385,9 @@ class GraphDocument:
def validate(self) -> None:
seen: set[str] = set()
root_names = [component.name for component in self.roots.values()]
if len(set(root_names)) != len(root_names):
raise ValueError("Root component names must be unique")
for component in self.all_components():
if component.id in seen:
raise ValueError(f"Duplicate component ID: {component.id}")
@@ -358,6 +398,11 @@ class GraphDocument:
@staticmethod
def _validate_graph(owner: Component) -> None:
child_names = [component.name for component in owner.graph.blocks.values()]
if len(set(child_names)) != len(child_names):
raise ValueError(
f"Component names inside {owner.name!r} must be unique"
)
input_ids = {port.id for port in owner.inputs}
output_ids = {port.id for port in owner.outputs}
if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs):

View File

@@ -2,12 +2,12 @@
"format": "bedit-document",
"version": 1,
"metadata": {
"name": "Example"
"name": "Untitled"
},
"roots": [
{
"id": "bf714517-e7b2-4f4b-8b53-55642e3beb28",
"name": "A",
"id": "7ad651ef-6f76-4064-81d3-3fd6dd4d2918",
"name": "gain",
"position": {
"x": 0.0,
"y": 0.0
@@ -16,15 +16,15 @@
"interface": {
"inputs": [
{
"id": "port-5c5d4695",
"name": "Port 1",
"id": "port-df34ce84",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 32.0,
"x": 64.0,
"y": 64.0
}
},
@@ -33,16 +33,16 @@
],
"outputs": [
{
"id": "port-de109124",
"name": "Port 2",
"id": "port-fe9e6486",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 96.0,
"y": 64.0
"x": 88.0,
"y": 40.0
}
},
"type": "signal"
@@ -77,31 +77,40 @@
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": "A",
"color": "#303030",
"fontSize": 12.0,
"text": "K",
"color": "#00007f",
"fontSize": 24.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"stroke": "#00007f",
"fill": "#ffffff"
}
]
},
"properties": {},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": [],
"parameters": {}
"equations": "out = k*in;",
"parameters": [
{
"id": "parameter-61a43a86",
"name": "k",
"type": "real",
"value": "1"
}
]
}
}
},
{
"id": "73c9d0c0-293d-4a55-8b89-a1f095dfa75f",
"name": "B",
"id": "12b21b11-8828-4a90-a561-19491dc9632e",
"name": "differentiate",
"position": {
"x": 0.0,
"y": 0.0
@@ -110,31 +119,16 @@
"interface": {
"inputs": [
{
"id": "port-4f732b3e",
"name": "Port 1",
"id": "port-1cbabc8f",
"name": "in",
"position": {
"x": -176.0,
"y": -144.0
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 32.0,
"y": 48.0
}
},
"type": "signal"
},
{
"id": "port-ef7c4218",
"name": "Port 2",
"position": {
"x": -176.0,
"y": -16.0
},
"properties": {
"iconPosition": {
"x": 32.0,
"y": 80.0
"x": 64.0,
"y": 64.0
}
},
"type": "signal"
@@ -142,16 +136,16 @@
],
"outputs": [
{
"id": "port-b68679b2",
"name": "Port 3",
"id": "port-4eeea4e7",
"name": "out",
"position": {
"x": 128.0,
"y": -80.0
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 96.0,
"y": 64.0
"x": 88.0,
"y": 40.0
}
},
"type": "signal"
@@ -162,7 +156,7 @@
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Graph",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
@@ -186,25 +180,34 @@
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": "B",
"color": "#303030",
"fontSize": 12.0,
"text": "d/dt",
"color": "#00007f",
"fontSize": 18.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"stroke": "#00007f",
"fill": "#ffffff"
}
]
},
"properties": {},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": []
"kind": "text",
"source": {
"equations": "initial out = initial;\nout = der(in);",
"parameters": [
{
"id": "parameter-331e38be",
"name": "initial",
"type": "real",
"value": "0"
}
]
}
}
}

View File

@@ -322,3 +322,14 @@ class EditTextDefinitionCommand(QUndoCommand):
def undo(self) -> None:
self.controller._set_text_definition(self.component_id, self.old)
def id(self) -> int:
return 1001
def mergeWith(self, other: QUndoCommand) -> bool: # noqa: N802
if not isinstance(other, EditTextDefinitionCommand):
return False
if other.component_id != self.component_id:
return False
self.new = other.new
return True

View File

@@ -31,6 +31,7 @@ from bedit.core.model import (
Endpoint,
GraphDocument,
Icon,
Parameter,
Port,
clone_component,
)
@@ -47,6 +48,7 @@ class DocumentController(QObject):
componentMoved = Signal(str, QPointF)
componentRotated = Signal(str, float)
componentPropertiesChanged = Signal(str)
textDefinitionChanged = Signal(str)
connectionAdded = Signal(str)
connectionRemoved = Signal(str)
graphItemChanged = Signal(str, str)
@@ -153,12 +155,13 @@ class DocumentController(QObject):
if self.document is None:
raise ValueError("Open or create a document first")
number = len(self.document.roots) + 1
base_name = f"New {'Graph' if kind == 'graph' else 'Text'} Block "
component = Component(
id=str(uuid4()),
name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}",
name=self._available_component_name(base_name, self.document.roots.values(), number),
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": [], "parameters": {}} if kind == "text" else {},
source={"equations": "", "parameters": []} if kind == "text" else {},
)
self.undo_stack.push(AddComponentCommand(self, None, component))
self.activate_component(component.id)
@@ -171,12 +174,13 @@ class DocumentController(QObject):
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
base_name = f"New {'Graph' if kind == 'graph' else 'Text'} Block "
component = Component(
id=str(uuid4()),
name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}",
name=self._available_component_name(base_name, owner.graph.blocks.values(), number),
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": [], "parameters": {}} if kind == "text" else {},
source={"equations": "", "parameters": []} if kind == "text" else {},
)
self.undo_stack.push(AddComponentCommand(self, owner_id, component))
return component.id
@@ -211,6 +215,9 @@ class DocumentController(QObject):
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.name = self._available_component_name(
source.name, self.active_component.graph.blocks.values(), 0
)
component.x, component.y = position.x(), position.y()
self.undo_stack.push(AddComponentCommand(self, self.active_component_id, component))
return component.id
@@ -470,7 +477,8 @@ class DocumentController(QObject):
self,
inputs: list[Port],
outputs: list[Port],
source: dict,
equations: str,
parameters: list[Parameter],
) -> None:
component = self.active_component
if component is None or component.implementation_kind != "text":
@@ -479,6 +487,13 @@ class DocumentController(QObject):
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 any(not port.name.strip() for port in (*inputs, *outputs)):
raise ValueError("Every port must have a name")
parameter_ids = [parameter.id for parameter in parameters]
if len(set(parameter_ids)) != len(parameter_ids):
raise ValueError("Parameter IDs must be unique")
if any(not parameter.name.strip() for parameter in parameters):
raise ValueError("Every parameter must have a name")
if self.document is not None:
parent = self.document.find_parent(component.id)
if parent is not None:
@@ -505,7 +520,10 @@ class DocumentController(QObject):
new = {
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"source": deepcopy(source),
"source": {
"equations": equations,
"parameters": [parameter.to_dict() for parameter in parameters],
},
}
if old != new:
self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new))
@@ -525,6 +543,9 @@ class DocumentController(QObject):
component = self.document.find_component(component_id)
if component is None:
return
siblings = self._component_siblings(component_id)
if any(item.id != component_id and item.name == name for item in siblings):
raise ValueError(f"A component named {name!r} already exists at this level")
old = {
"name": component.name,
"icon": component.icon.to_dict(),
@@ -726,10 +747,13 @@ class DocumentController(QObject):
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:
used = list(owner.graph.blocks.values())
for source, clone in pairs:
clone.name = self._available_component_name(source.name, used, 0)
clone.x += offset.x()
clone.y += offset.y()
blocks[clone.id] = clone
used.append(clone)
connections = {}
for source in source_connections:
if source.source.block not in id_map or source.target.block not in id_map:
@@ -759,6 +783,26 @@ class DocumentController(QObject):
raise ValueError("The containing component is no longer in the document")
return owner.graph
@staticmethod
def _available_component_name(
base: str, components, start: int = 0
) -> str:
used = {component.name for component in components}
number = start
while f"{base}{number}" in used:
number += 1
return f"{base}{number}"
def _component_siblings(self, component_id: str):
if self.document is None:
return ()
parent = self.document.find_parent(component_id)
return (
parent.graph.blocks.values()
if parent is not None
else self.document.roots.values()
)
def _insert_component(self, owner_id: str | None, component: Component) -> None:
if self.document is None:
raise ValueError("There is no open document")
@@ -1039,5 +1083,4 @@ class DocumentController(QObject):
component.source = deepcopy(values["source"])
self.interfaceChanged.emit()
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()
self.textDefinitionChanged.emit(component_id)

View File

@@ -0,0 +1 @@
"""Reusable editing widgets."""

View File

@@ -0,0 +1,193 @@
from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QComboBox, QHeaderView, QTableWidgetItem, QWidget
from bedit.core.model import Parameter, Port
from bedit.core.port_types import PortTypeRegistry
from bedit.gui.generated.ui_text_definition_editor import Ui_TextDefinitionEditor
ID_ROLE = Qt.ItemDataRole.UserRole
PROPERTIES_ROLE = Qt.ItemDataRole.UserRole + 1
class TextDefinitionEditor(QWidget):
"""Editor for a text component's equations, ports, and parameters."""
modifiedChanged = Signal(bool)
definitionEdited = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_TextDefinitionEditor()
self.ui.setupUi(self)
self._modified = False
self._loading = False
self.ui.portsTable.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.ui.parametersTable.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.ui.columnSplitter.setSizes([560, 340])
self.ui.definitionSplitter.setSizes([300, 300])
self.ui.equationsEdit.textChanged.connect(self._mark_modified)
self.ui.portsTable.cellChanged.connect(self._mark_modified)
self.ui.parametersTable.cellChanged.connect(self._mark_modified)
self.ui.addPortButton.clicked.connect(self.add_port)
self.ui.removePortButton.clicked.connect(self.remove_port)
self.ui.addParameterButton.clicked.connect(self.add_parameter)
self.ui.removeParameterButton.clicked.connect(self.remove_parameter)
self.ui.portsTable.itemSelectionChanged.connect(self._update_buttons)
self.ui.parametersTable.itemSelectionChanged.connect(self._update_buttons)
self._update_buttons()
@property
def is_modified(self) -> bool:
return self._modified
@property
def equations(self) -> str:
return self.ui.equationsEdit.toPlainText()
@property
def ports(self) -> tuple[list[Port], list[Port]]:
inputs: list[Port] = []
outputs: list[Port] = []
for row in range(self.ui.portsTable.rowCount()):
name_item = self.ui.portsTable.item(row, 0)
type_combo = self.ui.portsTable.cellWidget(row, 1)
orientation_combo = self.ui.portsTable.cellWidget(row, 2)
port = Port(
id=name_item.data(ID_ROLE),
name=name_item.text().strip(),
type=type_combo.currentData(),
properties=deepcopy(name_item.data(PROPERTIES_ROLE) or {}),
)
target = inputs if orientation_combo.currentData() == "input" else outputs
target.append(port)
return inputs, outputs
@property
def parameters(self) -> list[Parameter]:
table = self.ui.parametersTable
return [
Parameter(
id=table.item(row, 0).data(ID_ROLE),
name=table.item(row, 0).text().strip(),
type=table.item(row, 1).text().strip(),
value=table.item(row, 2).text(),
)
for row in range(table.rowCount())
]
def set_definition(
self,
equations: str,
inputs: list[Port],
outputs: list[Port],
parameters: list[Parameter],
) -> None:
self._loading = True
self.ui.equationsEdit.setPlainText(equations)
self.ui.portsTable.setRowCount(0)
for port in inputs:
self._append_port(port, "input")
for port in outputs:
self._append_port(port, "output")
self.ui.parametersTable.setRowCount(0)
for parameter in parameters:
self._append_parameter(parameter)
self._loading = False
self.set_modified(False)
self._update_buttons()
def set_modified(self, modified: bool) -> None:
if self._modified != modified:
self._modified = modified
self.modifiedChanged.emit(modified)
def _mark_modified(self, *_args) -> None:
if not self._loading:
self.set_modified(True)
self.definitionEdited.emit()
def _new_combo(self, values: list[tuple[str, str]], current: str) -> QComboBox:
combo = QComboBox(self)
for label, value in values:
combo.addItem(label, value)
combo.setCurrentIndex(max(0, combo.findData(current)))
combo.currentIndexChanged.connect(self._mark_modified)
return combo
def _append_port(self, port: Port, orientation: str) -> None:
table = self.ui.portsTable
row = table.rowCount()
table.insertRow(row)
name = QTableWidgetItem(port.name)
name.setData(ID_ROLE, port.id)
name.setData(PROPERTIES_ROLE, deepcopy(port.properties))
table.setItem(row, 0, name)
types = [(item.display_name, item.id) for item in PortTypeRegistry.all()]
table.setCellWidget(row, 1, self._new_combo(types, port.type))
table.setCellWidget(
row,
2,
self._new_combo([("Input", "input"), ("Output", "output")], orientation),
)
def _append_parameter(self, parameter: Parameter) -> None:
table = self.ui.parametersTable
row = table.rowCount()
table.insertRow(row)
name = QTableWidgetItem(parameter.name)
name.setData(ID_ROLE, parameter.id)
table.setItem(row, 0, name)
table.setItem(row, 1, QTableWidgetItem(parameter.type))
table.setItem(row, 2, QTableWidgetItem(parameter.value))
def add_port(self) -> None:
port = Port(
id=f"port-{uuid4().hex[:8]}",
name=f"Port {self.ui.portsTable.rowCount() + 1}",
type="signal",
properties={"iconPosition": {"x": 0.0, "y": 0.0}},
)
self._loading = True
self._append_port(port, "input")
self._loading = False
self.ui.portsTable.selectRow(self.ui.portsTable.rowCount() - 1)
self._mark_modified()
def remove_port(self) -> None:
row = self.ui.portsTable.currentRow()
if row >= 0:
self.ui.portsTable.removeRow(row)
self._mark_modified()
self._update_buttons()
def add_parameter(self) -> None:
parameter = Parameter(
id=f"parameter-{uuid4().hex[:8]}",
name=f"Parameter {self.ui.parametersTable.rowCount() + 1}",
)
self._loading = True
self._append_parameter(parameter)
self._loading = False
self.ui.parametersTable.selectRow(self.ui.parametersTable.rowCount() - 1)
self._mark_modified()
def remove_parameter(self) -> None:
row = self.ui.parametersTable.currentRow()
if row >= 0:
self.ui.parametersTable.removeRow(row)
self._mark_modified()
self._update_buttons()
def _update_buttons(self) -> None:
self.ui.removePortButton.setEnabled(self.ui.portsTable.currentRow() >= 0)
self.ui.removeParameterButton.setEnabled(
self.ui.parametersTable.currentRow() >= 0
)

View File

@@ -18,10 +18,11 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout,
QHeaderView, QLabel, QMainWindow, QMenu,
QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy,
QSpacerItem, QSplitter, QStackedWidget, QToolBar,
QToolButton, QTreeView, QVBoxLayout, QWidget)
QMenuBar, QSizePolicy, QSpacerItem, QSplitter,
QStackedWidget, QToolBar, QToolButton, QTreeView,
QVBoxLayout, QWidget)
from bedit.gui.editors.text_definition import TextDefinitionEditor
from bedit.gui.graphics.workspace import GraphWorkspaceView
from . import resources_rc
@@ -60,6 +61,8 @@ class Ui_MainWindow(object):
icon5 = QIcon()
icon5.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon5)
self.actionReloadLibraries = QAction(MainWindow)
self.actionReloadLibraries.setObjectName(u"actionReloadLibraries")
self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave")
icon6 = QIcon()
@@ -217,12 +220,6 @@ class Ui_MainWindow(object):
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")
icon15 = QIcon()
@@ -291,18 +288,17 @@ class Ui_MainWindow(object):
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.textPage = QWidget()
self.textPage.setObjectName(u"textPage")
self.textPageLayout = QVBoxLayout(self.textPage)
self.textPageLayout.setObjectName(u"textPageLayout")
self.textPageLayout.setContentsMargins(0, 0, 0, 0)
self.textDefinitionEditor = TextDefinitionEditor(self.textPage)
self.textDefinitionEditor.setObjectName(u"textDefinitionEditor")
self.jsonPageLayout.addWidget(self.jsonEditor)
self.textPageLayout.addWidget(self.textDefinitionEditor)
self.workspaceStack.addWidget(self.jsonPage)
self.workspaceStack.addWidget(self.textPage)
self.emptyPage = QWidget()
self.emptyPage.setObjectName(u"emptyPage")
self.emptyPage.setStyleSheet(u"background-color: #9a9a9a;")
@@ -364,6 +360,7 @@ class Ui_MainWindow(object):
self.menubar.addAction(self.menuHelp.menuAction())
self.menuFile.addAction(self.actionNew)
self.menuFile.addAction(self.actionOpen)
self.menuFile.addAction(self.actionReloadLibraries)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs)
@@ -439,6 +436,13 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionOpen.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionReloadLibraries.setText(QCoreApplication.translate("MainWindow", u"Reload &Libraries", None))
#if QT_CONFIG(statustip)
self.actionReloadLibraries.setStatusTip(QCoreApplication.translate("MainWindow", u"Reload configured library files from disk", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionReloadLibraries.setShortcut(QCoreApplication.translate("MainWindow", u"F5", None))
#endif // QT_CONFIG(shortcut)
self.actionSave.setText(QCoreApplication.translate("MainWindow", u"&Save", None))
#if QT_CONFIG(statustip)
@@ -505,7 +509,6 @@ class Ui_MainWindow(object):
self.navigateDownButton.setText(QCoreApplication.translate("MainWindow", u"Down", None))
self.graphBreadcrumbLabel.setText(QCoreApplication.translate("MainWindow", u"Untitled", None))
self.workspaceModeLabel.setText(QCoreApplication.translate("MainWindow", u"Graph", None))
self.applyJsonButton.setText(QCoreApplication.translate("MainWindow", u"Apply JSON", None))
self.pointerToolButton.setText(QCoreApplication.translate("MainWindow", u"Pointer", None))
self.connectToolButton.setText(QCoreApplication.translate("MainWindow", u"Connect", None))
#if QT_CONFIG(tooltip)
@@ -524,7 +527,6 @@ class Ui_MainWindow(object):
self.rotateToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise", None))
#endif // QT_CONFIG(tooltip)
self.rotateToolButton.setText(QCoreApplication.translate("MainWindow", u"Rotate", None))
self.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))

View File

@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'text_definition_editor.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 (QAbstractItemView, QApplication, QGroupBox, QHBoxLayout,
QHeaderView, QPlainTextEdit, QPushButton, QSizePolicy,
QSpacerItem, QSplitter, QTableWidget, QTableWidgetItem,
QVBoxLayout, QWidget)
class Ui_TextDefinitionEditor(object):
def setupUi(self, TextDefinitionEditor):
if not TextDefinitionEditor.objectName():
TextDefinitionEditor.setObjectName(u"TextDefinitionEditor")
TextDefinitionEditor.resize(900, 600)
self.editorLayout = QHBoxLayout(TextDefinitionEditor)
self.editorLayout.setObjectName(u"editorLayout")
self.editorLayout.setContentsMargins(6, 6, 6, 6)
self.columnSplitter = QSplitter(TextDefinitionEditor)
self.columnSplitter.setObjectName(u"columnSplitter")
self.columnSplitter.setOrientation(Qt.Orientation.Horizontal)
self.columnSplitter.setChildrenCollapsible(False)
self.equationsGroup = QGroupBox(self.columnSplitter)
self.equationsGroup.setObjectName(u"equationsGroup")
self.equationsLayout = QVBoxLayout(self.equationsGroup)
self.equationsLayout.setObjectName(u"equationsLayout")
self.equationsEdit = QPlainTextEdit(self.equationsGroup)
self.equationsEdit.setObjectName(u"equationsEdit")
self.equationsEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.equationsLayout.addWidget(self.equationsEdit)
self.columnSplitter.addWidget(self.equationsGroup)
self.definitionSplitter = QSplitter(self.columnSplitter)
self.definitionSplitter.setObjectName(u"definitionSplitter")
self.definitionSplitter.setOrientation(Qt.Orientation.Vertical)
self.definitionSplitter.setChildrenCollapsible(False)
self.portsGroup = QGroupBox(self.definitionSplitter)
self.portsGroup.setObjectName(u"portsGroup")
self.portsLayout = QVBoxLayout(self.portsGroup)
self.portsLayout.setObjectName(u"portsLayout")
self.portsTable = QTableWidget(self.portsGroup)
if (self.portsTable.columnCount() < 3):
self.portsTable.setColumnCount(3)
__qtablewidgetitem = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(0, __qtablewidgetitem)
__qtablewidgetitem1 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(1, __qtablewidgetitem1)
__qtablewidgetitem2 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(2, __qtablewidgetitem2)
self.portsTable.setObjectName(u"portsTable")
self.portsTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.portsTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.portsTable.setColumnCount(3)
self.portsLayout.addWidget(self.portsTable)
self.portButtonsLayout = QHBoxLayout()
self.portButtonsLayout.setObjectName(u"portButtonsLayout")
self.addPortButton = QPushButton(self.portsGroup)
self.addPortButton.setObjectName(u"addPortButton")
self.portButtonsLayout.addWidget(self.addPortButton)
self.removePortButton = QPushButton(self.portsGroup)
self.removePortButton.setObjectName(u"removePortButton")
self.portButtonsLayout.addWidget(self.removePortButton)
self.portButtonSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.portButtonsLayout.addItem(self.portButtonSpacer)
self.portsLayout.addLayout(self.portButtonsLayout)
self.definitionSplitter.addWidget(self.portsGroup)
self.parametersGroup = QGroupBox(self.definitionSplitter)
self.parametersGroup.setObjectName(u"parametersGroup")
self.parametersLayout = QVBoxLayout(self.parametersGroup)
self.parametersLayout.setObjectName(u"parametersLayout")
self.parametersTable = QTableWidget(self.parametersGroup)
if (self.parametersTable.columnCount() < 3):
self.parametersTable.setColumnCount(3)
__qtablewidgetitem3 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(0, __qtablewidgetitem3)
__qtablewidgetitem4 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(1, __qtablewidgetitem4)
__qtablewidgetitem5 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(2, __qtablewidgetitem5)
self.parametersTable.setObjectName(u"parametersTable")
self.parametersTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.parametersTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.parametersTable.setColumnCount(3)
self.parametersLayout.addWidget(self.parametersTable)
self.parameterButtonsLayout = QHBoxLayout()
self.parameterButtonsLayout.setObjectName(u"parameterButtonsLayout")
self.addParameterButton = QPushButton(self.parametersGroup)
self.addParameterButton.setObjectName(u"addParameterButton")
self.parameterButtonsLayout.addWidget(self.addParameterButton)
self.removeParameterButton = QPushButton(self.parametersGroup)
self.removeParameterButton.setObjectName(u"removeParameterButton")
self.parameterButtonsLayout.addWidget(self.removeParameterButton)
self.parameterButtonSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.parameterButtonsLayout.addItem(self.parameterButtonSpacer)
self.parametersLayout.addLayout(self.parameterButtonsLayout)
self.definitionSplitter.addWidget(self.parametersGroup)
self.columnSplitter.addWidget(self.definitionSplitter)
self.editorLayout.addWidget(self.columnSplitter)
self.retranslateUi(TextDefinitionEditor)
QMetaObject.connectSlotsByName(TextDefinitionEditor)
# setupUi
def retranslateUi(self, TextDefinitionEditor):
self.equationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Equations", None))
self.equationsEdit.setPlaceholderText(QCoreApplication.translate("TextDefinitionEditor", u"Enter equations here\u2026", None))
self.portsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Ports", None))
___qtablewidgetitem = self.portsTable.horizontalHeaderItem(0)
___qtablewidgetitem.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem1 = self.portsTable.horizontalHeaderItem(1)
___qtablewidgetitem1.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem2 = self.portsTable.horizontalHeaderItem(2)
___qtablewidgetitem2.setText(QCoreApplication.translate("TextDefinitionEditor", u"Orientation", None))
self.addPortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Port", None))
self.removePortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Port", None))
self.parametersGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Parameters", None))
___qtablewidgetitem3 = self.parametersTable.horizontalHeaderItem(0)
___qtablewidgetitem3.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem4 = self.parametersTable.horizontalHeaderItem(1)
___qtablewidgetitem4.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem5 = self.parametersTable.horizontalHeaderItem(2)
___qtablewidgetitem5.setText(QCoreApplication.translate("TextDefinitionEditor", u"Value", None))
self.addParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Parameter", None))
self.removeParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Parameter", None))
pass
# retranslateUi

View File

@@ -1,4 +1,3 @@
import json
from copy import deepcopy
from pathlib import Path
@@ -13,7 +12,7 @@ from PySide6.QtWidgets import (
QTabWidget,
)
from bedit.core.model import Component, Port
from bedit.core.model import Component, Parameter
from bedit.core.serializer import JsonDocumentSerializer
from bedit.gui.controllers.document import DocumentController
from bedit.gui.dialogs.component_options import ComponentOptionsDialog
@@ -40,6 +39,7 @@ class MainWindow(QMainWindow):
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.settings = application_settings()
self._applying_text_definition = False
self.libraries = LibraryRepository(self)
self.document_controller = DocumentController(self)
@@ -108,12 +108,18 @@ class MainWindow(QMainWindow):
self.ui.lineToolButton.clicked.connect(lambda: self.set_graph_tool("line"))
self.ui.textToolButton.clicked.connect(lambda: self.set_graph_tool("text"))
self.ui.rotateToolButton.clicked.connect(self.ui.graphView.rotate_selected)
self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.ui.textDefinitionEditor.definitionEdited.connect(
self.apply_text_definition
)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
self.document_controller.textDefinitionChanged.connect(
self._text_definition_changed
)
def _connect_actions(self) -> None:
self.ui.actionNew.triggered.connect(self.new_document)
self.ui.actionOpen.triggered.connect(self.open_document)
self.ui.actionReloadLibraries.triggered.connect(self.reload_libraries)
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)
@@ -189,7 +195,6 @@ class MainWindow(QMainWindow):
self.ui.navigateUpButton.setEnabled(False)
self.ui.workspaceModeLabel.setText("")
self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage)
self.ui.applyJsonButton.setVisible(False)
self._set_graph_controls_visible(False)
self._update_edit_actions()
return
@@ -199,13 +204,14 @@ class MainWindow(QMainWindow):
)
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)
self.ui.workspaceStack.setCurrentWidget(
self.ui.graphPage if is_graph else self.ui.textPage
)
self._set_graph_controls_visible(is_graph)
if is_graph:
self.set_graph_tool("pointer")
else:
self._load_source_json()
self._load_text_definition()
self._update_edit_actions()
def _update_edit_actions(self) -> None:
@@ -259,62 +265,70 @@ class MainWindow(QMainWindow):
"text": self.ui.textToolButton,
}[mode].setChecked(True)
def _load_source_json(self) -> None:
def _load_text_definition(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.textDefinitionEditor.set_definition(
component.source.get("equations", ""),
component.inputs,
component.outputs,
[
Parameter.from_dict(parameter)
for parameter in component.source.get("parameters", [])
],
)
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()
or not self.ui.textDefinitionEditor.is_modified
):
return True
answer = QMessageBox.question(
self,
"Apply text component changes?",
"The text component has unapplied input, output, or source changes.",
"The text component has unapplied equation, port, or parameter changes.",
QMessageBox.StandardButton.Apply
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
)
if answer == QMessageBox.StandardButton.Apply:
return self.apply_json()
return self.apply_text_definition()
return answer == QMessageBox.StandardButton.Discard
@Slot()
def apply_json(self) -> bool:
def apply_text_definition(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))
inputs, outputs = self.ui.textDefinitionEditor.ports
self._applying_text_definition = True
try:
self.document_controller.replace_active_text_definition(
inputs,
outputs,
self.ui.textDefinitionEditor.equations,
self.ui.textDefinitionEditor.parameters,
)
finally:
self._applying_text_definition = False
except (TypeError, ValueError) as error:
QMessageBox.critical(self, "Invalid text component", str(error))
return False
self._load_source_json()
self.ui.textDefinitionEditor.set_modified(False)
return True
@Slot(str)
def _text_definition_changed(self, component_id: str) -> None:
component = self.document_controller.active_component
if (
component is not None
and component.id == component_id
and not self._applying_text_definition
):
self._load_text_definition()
def _maybe_save(self) -> bool:
if self.document_controller.document is None:
return True
@@ -436,9 +450,9 @@ class MainWindow(QMainWindow):
ports_action = menu.addAction("Port Options…")
delete_action = menu.addAction("Delete")
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if selected is graph_action:
if graph_action is not None and selected is graph_action:
self.document_controller.add_child(component_id, "graph")
elif selected is text_action:
elif text_action is not None and selected is text_action:
self.document_controller.add_child(component_id, "text")
elif selected is options_action:
self.show_component_options(component_id)
@@ -521,15 +535,18 @@ class MainWindow(QMainWindow):
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.edited_icon,
dialog.edited_inputs,
dialog.edited_outputs,
dialog.ui.showSubtreeCheckBox.isChecked(),
dialog.ui.showNameCheckBox.isChecked(),
)
try:
self.document_controller.edit_component_appearance(
component_id,
dialog.ui.nameEdit.text().strip(),
dialog.edited_icon,
dialog.edited_inputs,
dialog.edited_outputs,
dialog.ui.showSubtreeCheckBox.isChecked(),
dialog.ui.showNameCheckBox.isChecked(),
)
except ValueError as error:
QMessageBox.warning(self, "Cannot rename component", str(error))
@Slot(str, str)
def show_port_options(self, port_id: str, direction: str) -> None: