Files
BondGraph/BEdit/src/bedit/gui/controllers/document.py

1333 lines
54 KiB
Python

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.gui.controllers.commands import (
AddComponentCommand,
AddConnectionCommand,
AddAnnotationCommand,
AddInterfacePortCommand,
DeleteSelectionCommand,
DeleteAnnotationsCommand,
EditGraphItemCommand,
EditGraphParametersCommand,
EditSimulationSettingsCommand,
EditTextDefinitionCommand,
EditComponentAppearanceCommand,
EditComponentPropertiesCommand,
EditComponentParametersCommand,
MoveComponentCommand,
MoveInterfacePortCommand,
PasteSelectionCommand,
RenameConnectionCommand,
RenameInterfacePortCommand,
ReplaceSourceCommand,
RotateComponentsCommand,
SplitConnectionCommand,
)
from bedit.core.model import (
Annotation,
Component,
Connection,
Endpoint,
GraphDocument,
Icon,
Junction,
Parameter,
Port,
clone_component,
)
from bedit.core.simulation import Simulation
from bedit.core.port_types import PortTypeRegistry
from bedit.core.serializer import DocumentSerializer
class DocumentController(QObject):
documentReset = Signal()
documentOpenedChanged = Signal(bool)
activeGraphChanged = Signal()
componentAdded = Signal(str)
componentRemoved = Signal(str)
componentMoved = Signal(str, QPointF)
componentRotated = Signal(str, float)
componentPropertiesChanged = Signal(str)
textDefinitionChanged = Signal(str)
connectionAdded = Signal(str)
connectionRemoved = Signal(str)
graphItemChanged = Signal(str, str)
annotationAdded = Signal(str)
annotationRemoved = Signal(str)
interfaceChanged = Signal()
filePathChanged = Signal(object)
modifiedChanged = Signal(bool)
def __init__(self, parent=None, *, simulation: Simulation | None = None) -> None:
super().__init__(parent)
self.simulation = simulation or Simulation()
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 = DocumentSerializer.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")
DocumentSerializer.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
base_name = f"New {'Graph' if kind == 'graph' else 'Text'} Block "
component = Component(
id=str(uuid4()),
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": ""} 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
base_name = f"New {'Graph' if kind == 'graph' else 'Text'} Block "
component = Component(
id=str(uuid4()),
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": ""} 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.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
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 move_junction(self, junction_id: str, old: QPointF, new: QPointF) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"junction_geometry",
junction_id,
{"x": old.x(), "y": old.y()},
{"x": new.x(), "y": new.y()},
"Move connection junction",
)
)
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,
*,
waypoints: list[QPointF] | None = None,
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
source_port = self._port_for_endpoint(source, "source")
target_port = self._port_for_endpoint(target, "target")
if source_port is None or target_port is None:
raise ValueError("A connection endpoint no longer exists")
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
raise ValueError(f"Cannot connect {source_port.type!r} to {target_port.type!r}")
if not self.endpoint_accepts_connection(source, "source"):
raise ValueError(
f"Port {source_port.name!r} already has a connection; enable multiple connections first"
)
if not self.endpoint_accepts_connection(target, "target"):
raise ValueError(
f"Port {target_port.name!r} already has a connection; enable multiple connections first"
)
connection = Connection(
str(uuid4()),
source,
target,
properties={
"waypoints": [{"x": point.x(), "y": point.y()} for point in (waypoints or [])],
},
)
self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection))
return connection.id
def split_connection(
self,
connection_id: str,
position: QPointF,
first_waypoints: list[QPointF],
second_waypoints: list[QPointF],
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
original = self.active_graph.connections.get(connection_id)
if original is None:
raise ValueError("The connection no longer exists")
port_type = self.connection_port_type(original)
junction = Junction(str(uuid4()), position.x(), position.y(), port_type)
first_properties = deepcopy(original.properties)
first_properties["waypoints"] = [
{"x": point.x(), "y": point.y()} for point in first_waypoints
]
second_properties = {
"waypoints": [{"x": point.x(), "y": point.y()} for point in second_waypoints]
}
first = Connection(
str(uuid4()),
original.source,
Endpoint(junction=junction.id),
original.name,
first_properties,
)
second = Connection(
str(uuid4()),
Endpoint(junction=junction.id),
original.target,
"",
second_properties,
)
self.undo_stack.push(
SplitConnectionCommand(
self,
self.active_component_id,
original,
junction,
first,
second,
)
)
return junction.id
def add_annotation(
self,
kind: str,
start: QPointF,
end: QPointF,
*,
text: str = "",
waypoints: list[QPointF] | None = None,
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
if kind != "line":
left, right = sorted((start.x(), end.x()))
top, bottom = sorted((start.y(), end.y()))
start, end = QPointF(left, top), QPointF(right, bottom)
style = {
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"fill": "none" if kind in {"line", "text"} else "#dbeafe",
}
if kind == "box":
style["cornerRadius"] = 0.0
if kind == "text":
style.update({"fontSize": 12.0, "color": "#202020"})
annotation = Annotation(
id=str(uuid4()),
kind=kind,
x=start.x(),
y=start.y(),
width=end.x() - start.x(),
height=end.y() - start.y(),
text=text,
layer=-1,
properties={
**style,
"waypoints": [{"x": point.x(), "y": point.y()} for point in (waypoints or [])],
},
)
self.undo_stack.push(AddAnnotationCommand(self, self.active_component_id, annotation))
return annotation.id
def edit_simulation_settings(self, settings: dict) -> None:
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before editing simulation settings")
old = deepcopy(component.graph.simulation_settings)
new = deepcopy(settings)
if old != new:
self.undo_stack.push(
EditSimulationSettingsCommand(self, component.id, old, new)
)
def edit_graph_parameter_values(
self, root_id: str, values: dict[str, dict[str, str]]
) -> None:
root = self.document.find_component(root_id) if self.document else None
if root is None:
return
subtree_ids = {component.id for component in self._component_subtree(root)}
if not set(values) <= subtree_ids:
raise ValueError("Parameter changes contain a component outside the active graph")
old: dict[str, list[dict]] = {}
new: dict[str, list[dict]] = {}
for component_id, parameter_values in values.items():
component = self.document.find_component(component_id)
known_ids = {parameter.id for parameter in component.parameters}
if not set(parameter_values) <= known_ids:
raise ValueError(f"Component {component.name!r} contains an unknown parameter")
updated = deepcopy(component.parameters)
for parameter in updated:
if parameter.id in parameter_values:
parameter.value = parameter_values[parameter.id]
old[component_id] = [parameter.to_dict() for parameter in component.parameters]
new[component_id] = [parameter.to_dict() for parameter in updated]
if old != new:
self.undo_stack.push(EditGraphParametersCommand(self, old, new))
def compile_active_graph(self) -> None:
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before compiling")
self.simulation.compile(component.to_dict())
def run_simulation(self) -> None:
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before running a simulation")
self.simulation.run_simulation()
def set_route_waypoints(self, item_kind: str, item_id: str, waypoints: list[QPointF]) -> None:
item = (
self.active_graph.connections
if item_kind == "connection"
else self.active_graph.annotations
).get(item_id)
if item is None or self.active_component_id is None:
return
old = deepcopy(item.properties)
new = deepcopy(old)
new["waypoints"] = [{"x": point.x(), "y": point.y()} for point in waypoints]
if old != new:
self.undo_stack.push(
EditGraphItemCommand(
self, self.active_component_id, item_kind, item_id, old, new, "Edit line nodes"
)
)
def set_annotation_geometry(self, annotation_id: str, old: dict, new: dict) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"annotation_geometry",
annotation_id,
old,
new,
"Move annotation",
)
)
def edit_annotation(self, annotation_id: str, values: dict) -> None:
annotation = self.active_graph.annotations.get(annotation_id)
if annotation is None or self.active_component_id is None:
return
old = annotation.to_dict()
if old != values:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"annotation_data",
annotation_id,
old,
deepcopy(values),
"Edit shape",
)
)
def reorder_annotations(self, annotation_ids: set[str], operation: str) -> None:
if not annotation_ids or self.active_component_id is None:
return
graph = self.active_graph
layers = [item.layer for item in graph.annotations.values()]
minimum, maximum = min(layers, default=-1), max(layers, default=1)
for annotation_id in annotation_ids:
item = graph.annotations.get(annotation_id)
if item is None:
continue
old = {"layer": item.layer}
forward = item.layer + 1
backward = item.layer - 1
if forward == 0:
forward = 1
if backward == 0:
backward = -1
layer = {
"forward": forward,
"backward": backward,
"front": max(1, maximum + 1),
"back": min(-1, minimum - 1),
}[operation]
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"annotation_layer",
annotation_id,
old,
{"layer": layer},
"Reorder annotation",
)
)
def delete_annotations(self, annotation_ids: set[str]) -> None:
items = {
key: self.active_graph.annotations[key]
for key in annotation_ids
if key in self.active_graph.annotations
}
if items and self.active_component_id is not None:
self.undo_stack.push(DeleteAnnotationsCommand(self, self.active_component_id, items))
def _port_for_endpoint(self, endpoint: Endpoint, role: str) -> Port | None:
owner = self.active_component
if owner is None:
return None
if endpoint.junction is not None:
junction = owner.graph.junctions.get(endpoint.junction)
if junction is None:
return None
return Port(
junction.id,
"Junction",
type=junction.type,
allows_multiple_connections=role == "source",
)
if endpoint.interface is not None:
ports = owner.inputs if role == "source" else owner.outputs
else:
component = owner.graph.blocks.get(endpoint.block or "")
if component is None:
return None
ports = component.outputs if role == "source" else component.inputs
return next(
(port for port in ports if port.id == (endpoint.interface or endpoint.port)), None
)
def connection_port_type(self, connection: Connection) -> str:
port = self._port_for_endpoint(connection.source, "source")
return port.type if port is not None else "signal"
def endpoint_accepts_connection(self, endpoint: Endpoint, role: str) -> bool:
port = self._port_for_endpoint(endpoint, role)
if port is None:
return False
if role == "source" or port.allows_multiple_connections:
return True
return not any(
endpoint == (connection.source if role == "source" else connection.target)
for connection in self.active_graph.connections.values()
)
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],
equations: str,
parameters: list[Parameter],
) -> 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 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:
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),
"parameters": [parameter.to_dict() for parameter in component.parameters],
}
new = {
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"source": {
"equations": equations,
},
"parameters": [parameter.to_dict() for parameter in parameters],
}
if old != new:
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component.id)
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate_component.source = deepcopy(new["source"])
candidate_component.parameters = deepcopy(parameters)
candidate.validate()
self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new))
def edit_component_appearance(
self,
component_id: str,
name: str,
icon: Icon,
inputs: list[Port],
outputs: list[Port],
show_subtree: bool,
show_name: bool,
) -> None:
if self.document is None:
return
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(),
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"show_subtree": component.show_subtree_in_library,
"properties": deepcopy(component.properties),
}
properties = deepcopy(component.properties)
was_visible = bool(properties.get("showName", False))
properties["showName"] = show_name
if show_name and not was_visible:
properties.pop("nameLabelPosition", None)
new = {
"name": name,
"icon": icon.to_dict(),
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"show_subtree": show_subtree,
"properties": properties,
}
if old != new:
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component_id)
candidate_component.name = name
candidate_component.icon = Icon.from_dict(icon.to_dict())
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate_component.properties = deepcopy(properties)
candidate.validate()
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
def move_component_name_label(self, component_id: str, position: QPointF) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
old = deepcopy(component.properties)
new = deepcopy(old)
new["nameLabelPosition"] = {"x": position.x(), "y": position.y()}
if old != new:
self.undo_stack.push(
EditComponentPropertiesCommand(
self, component_id, old, new, "Move component name"
)
)
def edit_connection_options(
self, connection_id: str, name: str, show_name: bool
) -> None:
owner = self.active_component
if owner is None or self.active_component_id is None:
return
connection = owner.graph.connections.get(connection_id)
if connection is None:
return
old = {"name": connection.name, "properties": deepcopy(connection.properties)}
properties = deepcopy(connection.properties)
was_visible = bool(properties.get("showName", False))
properties["showName"] = show_name
if show_name and not was_visible:
properties.pop("nameLabelPosition", None)
new = {"name": name, "properties": properties}
if old != new:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"connection_data",
connection_id,
old,
new,
"Edit connection options",
)
)
def move_connection_name_label(self, connection_id: str, position: QPointF) -> None:
connection = self.active_graph.connections.get(connection_id)
if connection is None or self.active_component_id is None:
return
old = deepcopy(connection.properties)
new = deepcopy(old)
new["nameLabelPosition"] = {"x": position.x(), "y": position.y()}
if old != new:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"connection",
connection_id,
old,
new,
"Move connection name",
)
)
def edit_component_ports(
self, component_id: str, inputs: list[Port], outputs: list[Port]
) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
input_ids = {port.id for port in inputs}
output_ids = {port.id for port in outputs}
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("An input cannot be removed or reoriented while connected")
if (
connection.source.block == component_id
and connection.source.port not in output_ids
):
raise ValueError("An output cannot be removed or reoriented while connected")
for connection in component.graph.connections.values():
if connection.source.interface and connection.source.interface not in input_ids:
raise ValueError("An interface input cannot be removed while connected")
if connection.target.interface and connection.target.interface not in output_ids:
raise ValueError("An interface output cannot be removed while connected")
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component_id)
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate.validate()
old = {
"name": component.name,
"icon": component.icon.to_dict(),
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"show_subtree": component.show_subtree_in_library,
"properties": deepcopy(component.properties),
}
new = {
**old,
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
}
if old != new:
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
def edit_component_parameters(
self, component_id: str, parameters: list[Parameter]
) -> None:
component = self.document.find_component(component_id) if self.document else None
if component is None:
return
ids = [parameter.id for parameter in parameters]
names = [parameter.name for parameter in parameters]
if len(set(ids)) != len(ids):
raise ValueError("Parameter IDs must be unique")
if len(set(names)) != len(names):
raise ValueError("Parameter names must be unique")
if any(not name.strip() for name in names):
raise ValueError("Every parameter must have a name")
old = [parameter.to_dict() for parameter in component.parameters]
new = [parameter.to_dict() for parameter in parameters]
if old != new:
self.undo_stack.push(
EditComponentParametersCommand(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 = {}
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:
continue
properties = deepcopy(source.properties)
for point in properties.get("waypoints", []):
if isinstance(point, dict):
point["x"] = float(point.get("x", 0)) + offset.x()
point["y"] = float(point.get("y", 0)) + offset.y()
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),
name=source.name,
properties=properties,
)
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
@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")
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 _split_connection(
self,
owner_id: str,
original_id: str,
junction: Junction,
first: Connection,
second: Connection,
) -> None:
graph = self._graph_for(owner_id)
graph.connections.pop(original_id, None)
graph.junctions[junction.id] = junction
graph.connections[first.id] = first
graph.connections[second.id] = second
self.documentReset.emit()
def _restore_split_connection(
self,
owner_id: str,
original: Connection,
junction_id: str,
first_id: str,
second_id: str,
) -> None:
graph = self._graph_for(owner_id)
graph.connections.pop(first_id, None)
graph.connections.pop(second_id, None)
graph.junctions.pop(junction_id, None)
graph.connections[original.id] = original
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 _insert_annotation(self, owner_id: str, annotation: Annotation) -> None:
self._graph_for(owner_id).annotations[annotation.id] = annotation
if owner_id == self.active_component_id:
self.annotationAdded.emit(annotation.id)
self.documentReset.emit()
def _remove_annotation(self, owner_id: str, annotation_id: str) -> None:
self._graph_for(owner_id).annotations.pop(annotation_id, None)
if owner_id == self.active_component_id:
self.annotationRemoved.emit(annotation_id)
self.documentReset.emit()
def _set_graph_item_data(
self, owner_id: str, item_kind: str, item_id: str, values: dict
) -> None:
graph = self._graph_for(owner_id)
if item_kind == "junction_geometry":
item = graph.junctions.get(item_id)
if item is not None:
item.x, item.y = float(values["x"]), float(values["y"])
elif item_kind == "connection_data":
item = graph.connections.get(item_id)
if item is not None:
item.name = values["name"]
item.properties = deepcopy(values["properties"])
elif item_kind == "connection":
item = graph.connections.get(item_id)
if item is not None:
item.properties = deepcopy(values)
else:
item = graph.annotations.get(item_id)
if item is not None:
if item_kind == "annotation_geometry":
item.x, item.y = float(values["x"]), float(values["y"])
item.width, item.height = float(values["width"]), float(values["height"])
elif item_kind == "annotation_layer":
item.layer = int(values["layer"])
elif item_kind == "annotation_data":
replacement = Annotation.from_dict(values)
item.kind = replacement.kind
item.x, item.y = replacement.x, replacement.y
item.width, item.height = replacement.width, replacement.height
item.text, item.layer = replacement.text, replacement.layer
item.properties = replacement.properties
else:
item.properties = deepcopy(values)
if owner_id == self.active_component_id:
self.graphItemChanged.emit(item_kind, item_id)
def _set_simulation_settings(self, owner_id: str, settings: dict) -> None:
owner = self.document.find_component(owner_id) if self.document else None
if owner is not None and owner.implementation_kind == "graph":
owner.graph.simulation_settings = deepcopy(settings)
self.documentReset.emit()
def _set_graph_parameters(self, values: dict[str, list[dict]]) -> None:
if self.document is None:
return
changed_text_components: list[str] = []
for component_id, parameters in values.items():
component = self.document.find_component(component_id)
if component is None:
continue
component.parameters = [Parameter.from_dict(item) for item in parameters]
if component.implementation_kind == "text":
changed_text_components.append(component_id)
self.document.validate()
self.documentReset.emit()
for component_id in changed_text_components:
self.textDefinitionChanged.emit(component_id)
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.from_dict(values["icon"])
component.inputs = [Port.from_dict(port) for port in values["inputs"]]
component.outputs = [Port.from_dict(port) for port in values["outputs"]]
component.show_subtree_in_library = values["show_subtree"]
component.properties = deepcopy(values["properties"])
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()
def _set_component_properties(self, component_id: str, properties: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is not None:
component.properties = deepcopy(properties)
self.componentPropertiesChanged.emit(component_id)
def _set_component_parameters(self, component_id: str, values: list[dict]) -> None:
component = self.document.find_component(component_id) if self.document else None
if component is not None:
component.parameters = [Parameter.from_dict(item) for item in values]
self.documentReset.emit()
if (
component_id == self.active_component_id
and component.implementation_kind == "text"
):
self.textDefinitionChanged.emit(component_id)
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"])
component.parameters = [
Parameter.from_dict(item) for item in values.get("parameters", [])
]
self.interfaceChanged.emit()
self.documentReset.emit()
self.textDefinitionChanged.emit(component_id)