diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index 88a22af..1be9003 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -54,6 +54,8 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant - Components may contain nested graph or text implementations. - Ports retain stable IDs. Display names, types, orientation, positions, and icon anchors may change without changing IDs. +- Parameters belong to `Component` rather than a particular implementation kind, + so graph and text components share stable-ID name/type/value records. - Port orientation is presented as one unified list in the UI, while the model indexes inputs and outputs separately for connection semantics. - Port types are registered in `core/port_types.py`. Only compatible types may be @@ -92,9 +94,23 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant - Avoid the QSettings group name `general`; Qt treats `General` specially in INI files. Autosave keys live under `autosave/`. - User-visible document edits should participate in undo/redo. +- The text-definition editor uses OpenModelica highlighting and completion from + `src/bedit/data/syntax/openmodelica.json`. Keep keywords, types, built-ins, and + named BEdit `$name$` macro completions editable there; arbitrary `$name$` + expressions are highlighted as BEvalues. Highlight colors and bold/italic + styles are persisted under `syntax//` in application settings. - Application-wide messages use `core.application_log.get_logger()`. The main window installs the Qt log-panel handler; core code must only use standard Python logging and must not import the GUI handler. +- File → Reload Simulation Code (`Ctrl+F5`) reloads modules under + `bedit.core.simulation`, replaces the shared application/controller service, + and preserves the previous instance attributes where possible. +- Simulation compilation lives in `core/simulation/compiler.py`; the simulation + service only owns application state and delegates compilation. Ports with + `multipleConnections` are emitted as Modelica arrays. Their size is inferred + per component instance from graph connections and exposed while compiling as + `$portname_N$`; array connection endpoints receive stable one-based indices in + graph connection order. ## Qt Designer and generated files @@ -134,6 +150,9 @@ pyside6-uic --from-imports ui/text_definition_editor.ui \ pyside6-uic --from-imports ui/simulation_settings_dialog.ui \ -o src/bedit/gui/generated/ui_simulation_settings_dialog.py + +pyside6-uic --from-imports ui/parameter_options_dialog.ui \ + -o src/bedit/gui/generated/ui_parameter_options_dialog.py ``` When adding a promoted/custom widget in Designer, its header must use the real diff --git a/BEdit/pyproject.toml b/BEdit/pyproject.toml index 43133fc..790c93e 100644 --- a/BEdit/pyproject.toml +++ b/BEdit/pyproject.toml @@ -26,7 +26,11 @@ bedit = "bedit.gui.app:main" where = ["src"] [tool.setuptools.package-data] -bedit = ["data/libraries/*.json", "data/libraries/*.beb"] +bedit = [ + "data/libraries/*.json", + "data/libraries/*.beb", + "data/syntax/*.json", +] [tool.ruff] line-length = 100 diff --git a/BEdit/src/bedit/core/model.py b/BEdit/src/bedit/core/model.py index 0b8c1d4..c50a699 100644 --- a/BEdit/src/bedit/core/model.py +++ b/BEdit/src/bedit/core/model.py @@ -173,9 +173,9 @@ class Parameter: @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") + raise ValueError("Each component parameter must be an object") if "id" not in data: - raise ValueError("Each text component parameter must have an ID") + raise ValueError("Each component parameter must have an ID") return cls( id=str(data["id"]), name=str(data.get("name", "")), @@ -301,6 +301,7 @@ class Component: rotation: float = 0.0 inputs: list[Port] = field(default_factory=list) outputs: list[Port] = field(default_factory=list) + parameters: list[Parameter] = field(default_factory=list) icon: Icon = field(default_factory=Icon) properties: dict[str, Any] = field(default_factory=dict) implementation_kind: str = "graph" @@ -323,6 +324,7 @@ class Component: "inputs": [port.to_dict() for port in self.inputs], "outputs": [port.to_dict() for port in self.outputs], }, + "parameters": [parameter.to_dict() for parameter in self.parameters], "icon": self.icon.to_dict(), "properties": self.properties, "library": {"showSubtree": self.show_subtree_in_library}, @@ -338,18 +340,18 @@ class Component: if kind not in {"graph", "text"}: raise ValueError(f"Unknown component implementation kind: {kind}") source: dict[str, Any] = {} + parameters = data.get("parameters", []) if kind == "text": raw_source = implementation.get("source", {}) equations = raw_source.get("equations", "") - parameters = raw_source.get("parameters", []) + parameters = data.get("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], } + if not isinstance(parameters, list): + raise ValueError("Component parameters must be a list") return cls( id=str(data["id"]), name=str(data.get("name", "Unnamed")), @@ -358,6 +360,7 @@ class Component: 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", [])], + parameters=[Parameter.from_dict(item) for item in parameters], icon=Icon.from_dict(data.get("icon")), properties=dict(data.get("properties", {})), show_subtree_in_library=bool(data.get("library", {}).get("showSubtree", True)), @@ -434,6 +437,14 @@ class GraphDocument: if component.id in seen: raise ValueError(f"Duplicate component ID: {component.id}") seen.add(component.id) + parameter_ids = [parameter.id for parameter in component.parameters] + parameter_names = [parameter.name for parameter in component.parameters] + if len(set(parameter_ids)) != len(parameter_ids): + raise ValueError(f"Component {component.name!r} has duplicate parameter IDs") + if len(set(parameter_names)) != len(parameter_names): + raise ValueError(f"Component {component.name!r} has duplicate parameter names") + if any(not name.strip() for name in parameter_names): + raise ValueError(f"Component {component.name!r} has an unnamed parameter") if component.implementation_kind == "text" and component.graph.blocks: raise ValueError(f"Text component {component.name} cannot contain a graph") self._validate_graph(component) @@ -612,6 +623,7 @@ def clone_component(source: Component) -> Component: ) for port in current.outputs ], + parameters=deepcopy(current.parameters), icon=Icon.from_dict(current.icon.to_dict()), properties=deepcopy(current.properties), implementation_kind=current.implementation_kind, diff --git a/BEdit/src/bedit/core/simulation/compiler.py b/BEdit/src/bedit/core/simulation/compiler.py new file mode 100644 index 0000000..cb91777 --- /dev/null +++ b/BEdit/src/bedit/core/simulation/compiler.py @@ -0,0 +1,326 @@ +import re +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + + +_MODELICA_TYPES = { + "real": "Real", + "integer": "Integer", + "boolean": "Boolean", + "string": "String", +} +_BEVALUE_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)\$") + + +@dataclass(frozen=True) +class CompilationResult: + """The intermediate data and generated source produced by compilation.""" + + graph: dict[str, Any] + objects_by_id: dict[str, Any] + modelica: str + + +def compile_graph(graph: dict[str, Any]) -> CompilationResult: + """Clean, index, and emit a serialized component tree.""" + + cleaned_graph = cleanup_graph(deepcopy(graph)) + objects_by_id = build_id_list(cleaned_graph) + return CompilationResult( + graph=cleaned_graph, + objects_by_id=objects_by_id, + modelica=emit_model(cleaned_graph, objects_by_id), + ) + + +def build_id_list(graph: dict[str, Any]) -> dict[str, Any]: + """Index all addressable objects in a component tree by their stable ID.""" + + id_list: dict[str, Any] = {} + id_kinds: dict[str, str] = {} + + def add(item: dict[str, Any], description: str) -> None: + item_id = item.get("id") + if not item_id: + raise ValueError(f"{description} has no ID") + # Port IDs identify a port on a component definition and may therefore + # recur in cloned component instances. Component and junction IDs are + # document objects and must remain globally unique. + if item_id in id_list and not ( + description == "port" and id_kinds[item_id] == "port" + ): + raise ValueError(f"Duplicate simulation object ID: {item_id}") + id_list[item_id] = item + id_kinds[item_id] = description + + def visit(component: dict[str, Any]) -> None: + add(component, "component") + interface = component.get("interface", {}) + for port in interface.get("inputs", []): + add(port, "port") + for port in interface.get("outputs", []): + add(port, "port") + + implementation = component.get("implementation", {}) + if implementation.get("kind") != "graph": + return + nested_graph = implementation.get("graph", {}) + for junction in nested_graph.get("junctions", []): + add(junction, "junction") + for block in nested_graph.get("blocks", []): + visit(block) + + visit(graph) + return id_list + + +def emit_model( + graph: dict[str, Any], + id_list: dict[str, Any], + indent: int = 0, + connection_counts: dict[str, int] | None = None, +) -> str: + """Emit a component and its nested definitions as Modelica source.""" + + del id_list # Kept in the public API for compiler extensions and inspection. + indentation = "\t" * indent + body_indent = "\t" * (indent + 1) + model_name = identifier(graph["id"]) + lines = [f"{indentation}model {model_name}"] + implementation = graph.get("implementation", {}) + implementation_kind = implementation.get("kind") + nested_graph = implementation.get("graph", {}) + port_counts = connection_counts or _interface_connection_counts(graph) + macros = _port_count_macros(graph, port_counts) + + if implementation_kind == "graph": + for block in nested_graph.get("blocks", []): + lines.extend( + emit_model( + block, + {}, + indent + 1, + _block_connection_counts(nested_graph, block["id"]), + ) + .rstrip() + .splitlines() + ) + + interface = graph.get("interface", {}) + for port in interface.get("inputs", []): + lines.append(_port_declaration(port, "input", indent + 1, macros)) + for port in interface.get("outputs", []): + lines.append(_port_declaration(port, "output", indent + 1, macros)) + + for parameter in graph.get("parameters", []): + parameter_type = modelica_type(parameter.get("type", "real")) + parameter_name = identifier(parameter["name"]) + value = expand_bevalues(str(parameter.get("value", "0")), macros) + lines.append( + f"{body_indent}parameter {parameter_type} {parameter_name} = {value};" + ) + + if implementation_kind == "graph": + for block in nested_graph.get("blocks", []): + block_type = identifier(block["id"]) + block_name = identifier(block["name"]) + lines.append(f"{body_indent}{block_type} {block_name};") + for junction in nested_graph.get("junctions", []): + junction_type = modelica_type(junction.get("type", "signal")) + lines.append( + f"{body_indent}{junction_type} {_junction_name(junction['id'])};" + ) + + lines.append(f"{indentation}equation") + if implementation_kind == "graph": + blocks = {block["id"]: block for block in nested_graph.get("blocks", [])} + junctions = { + junction["id"]: junction for junction in nested_graph.get("junctions", []) + } + endpoint_indices: dict[tuple[str, str, str], int] = {} + for connection in nested_graph.get("connections", []): + source = _endpoint_expression( + connection["source"], graph, blocks, junctions, endpoint_indices + ) + target = _endpoint_expression( + connection["target"], graph, blocks, junctions, endpoint_indices + ) + # Connector types may require different equations in future. + lines.append(f"{body_indent}{target} = {source};") + else: + equations = str(implementation.get("source", {}).get("equations", "")) + equations = expand_bevalues(equations, macros) + lines.extend( + f"{body_indent}{line}" if line.strip() else "" + for line in equations.splitlines() + ) + + lines.append(f"{indentation}end {model_name};") + return "\n".join(lines) + "\n" + + +def cleanup_graph(graph: dict[str, Any]) -> dict[str, Any]: + """Remove annotations and UI-only data from a serialized component tree.""" + + def remove_key_with_lists(data: Any, target_key: str) -> None: + if isinstance(data, dict): + for key in list(data): + if key == target_key: + del data[key] + else: + remove_key_with_lists(data[key], target_key) + elif isinstance(data, list): + for item in data: + remove_key_with_lists(item, target_key) + + for key in ( + "position", + "rotation", + "iconPosition", + "icon", + "annotations", + "library", + "properties", + ): + remove_key_with_lists(graph, key) + return graph + + +def _port_declaration( + port: dict[str, Any], direction: str, indent: int, macros: dict[str, str] +) -> str: + port_type = modelica_type(port.get("type", "signal")) + indentation = "\t" * indent + port_name = identifier(port["name"]) + dimension = f"[${port_name}_N$]" if port.get("multipleConnections", False) else "" + declaration = f"{indentation}{direction} {port_type} {port_name}{dimension};" + return expand_bevalues(declaration, macros) + + +def _endpoint_expression( + endpoint: dict[str, Any], + owner: dict[str, Any], + blocks: dict[str, dict[str, Any]], + junctions: dict[str, dict[str, Any]], + endpoint_indices: dict[tuple[str, str, str], int], +) -> str: + if "junction" in endpoint: + junction_id = endpoint["junction"] + if junction_id not in junctions: + raise ValueError(f"Connection references unknown junction {junction_id!r}") + return _junction_name(junction_id) + + if "interface" in endpoint: + port = _find_port(owner, endpoint["interface"]) + expression = identifier(port["name"]) + return _index_array_endpoint( + expression, port, ("interface", owner["id"], port["id"]), endpoint_indices + ) + + block_id = endpoint.get("block") + port_id = endpoint.get("port") + block = blocks.get(block_id) + if block is None: + raise ValueError(f"Connection references unknown block {block_id!r}") + port = _find_port(block, port_id) + expression = f"{identifier(block['name'])}.{identifier(port['name'])}" + return _index_array_endpoint( + expression, port, ("block", block_id, port_id), endpoint_indices + ) + + +def _index_array_endpoint( + expression: str, + port: dict[str, Any], + key: tuple[str, str, str], + endpoint_indices: dict[tuple[str, str, str], int], +) -> str: + if not port.get("multipleConnections", False): + return expression + endpoint_indices[key] = endpoint_indices.get(key, 0) + 1 + return f"{expression}[{endpoint_indices[key]}]" + + +def _block_connection_counts( + graph: dict[str, Any], block_id: str +) -> dict[str, int]: + counts: dict[str, int] = {} + for connection in graph.get("connections", []): + for endpoint in (connection.get("source", {}), connection.get("target", {})): + if endpoint.get("block") == block_id and endpoint.get("port"): + port_id = endpoint["port"] + counts[port_id] = counts.get(port_id, 0) + 1 + return counts + + +def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]: + implementation = component.get("implementation", {}) + if implementation.get("kind") != "graph": + return {} + counts: dict[str, int] = {} + for connection in implementation.get("graph", {}).get("connections", []): + for endpoint in (connection.get("source", {}), connection.get("target", {})): + if endpoint.get("interface"): + port_id = endpoint["interface"] + counts[port_id] = counts.get(port_id, 0) + 1 + return counts + + +def _port_count_macros( + component: dict[str, Any], connection_counts: dict[str, int] +) -> dict[str, str]: + macros: dict[str, str] = {} + interface = component.get("interface", {}) + for port in (*interface.get("inputs", []), *interface.get("outputs", [])): + if port.get("multipleConnections", False): + macros[f"{identifier(port['name'])}_N"] = str( + connection_counts.get(port["id"], 0) + ) + return macros + + +def expand_bevalues(text: str, values: dict[str, str]) -> str: + """Replace BEdit ``$name$`` macros and reject unresolved compiler values.""" + + def replace(match: re.Match[str]) -> str: + name = match.group(1) + if name not in values: + raise ValueError(f"Unknown BEdit value ${name}$") + return values[name] + + return _BEVALUE_PATTERN.sub(replace, text) + + +def _find_port(component: dict[str, Any], port_id: str) -> dict[str, Any]: + interface = component.get("interface", {}) + ports = [*interface.get("inputs", []), *interface.get("outputs", [])] + for port in ports: + if port.get("id") == port_id: + return port + raise ValueError( + f"Component {component.get('name', component.get('id', '?'))!r} " + f"has no port {port_id!r}" + ) + + +def modelica_type(value: str) -> str: + normalized = str(value).strip().lower() + if normalized in {"signal", "signal array"}: + return "Real" + return _MODELICA_TYPES.get(normalized, identifier(str(value))) + + +def identifier(value: str) -> str: + """Return a safe unquoted Modelica identifier.""" + + normalized = re.sub(r"[^A-Za-z0-9_]", "_", str(value).strip()) + if not normalized: + raise ValueError("Modelica names cannot be empty") + if normalized[0].isdigit(): + normalized = f"model_{normalized}" + return normalized + + +def _junction_name(junction_id: str) -> str: + return identifier(f"junction_{junction_id}") diff --git a/BEdit/src/bedit/core/simulation/service.py b/BEdit/src/bedit/core/simulation/service.py index 348e2e6..0f1fe3d 100644 --- a/BEdit/src/bedit/core/simulation/service.py +++ b/BEdit/src/bedit/core/simulation/service.py @@ -1,18 +1,26 @@ -from copy import deepcopy from typing import Any +from bedit.core.application_log import get_logger +from bedit.core.simulation.compiler import compile_graph + + +log = get_logger(__name__) + class Simulation: - """Application-owned simulation service and state container.""" + """Application-owned simulation state and compiler facade.""" def __init__(self) -> None: self.state: dict[str, Any] = {} self.last_compilation_input: dict[str, Any] | None = None + self.last_compilation_output: str | None = None + self.id_list: dict[str, Any] = {} def compile(self, graph: dict[str, Any]) -> None: - """Compile a graph definition. + """Compile a serialized component tree and retain the result.""" - This is intentionally a stub. Keeping a copy of the input makes the - service useful for incremental compiler development and UI inspection. - """ - self.last_compilation_input = deepcopy(graph) + result = compile_graph(graph) + self.last_compilation_input = result.graph + self.id_list = result.objects_by_id + self.last_compilation_output = result.modelica + log.info("Generated Modelica model:\n%s", self.last_compilation_output) diff --git a/BEdit/src/bedit/data/libraries/default.beb b/BEdit/src/bedit/data/libraries/default.beb deleted file mode 100644 index a467ebe..0000000 Binary files a/BEdit/src/bedit/data/libraries/default.beb and /dev/null differ diff --git a/BEdit/src/bedit/data/syntax/openmodelica.json b/BEdit/src/bedit/data/syntax/openmodelica.json new file mode 100644 index 0000000..1342d1a --- /dev/null +++ b/BEdit/src/bedit/data/syntax/openmodelica.json @@ -0,0 +1,22 @@ +{ + "keywords": [ + "algorithm", "and", "annotation", "block", "break", "class", "connect", + "connector", "constant", "constrainedby", "der", "discrete", "each", "else", + "elseif", "elsewhen", "encapsulated", "end", "enumeration", "equation", + "expandable", "extends", "external", "false", "final", "flow", "for", + "function", "if", "import", "impure", "in", "initial", "inner", "input", + "loop", "model", "not", "operator", "or", "outer", "output", "package", + "parameter", "partial", "protected", "public", "pure", "record", "redeclare", + "replaceable", "return", "stream", "then", "true", "type", "when", "while", + "within" + ], + "types": ["Boolean", "Integer", "Real", "String"], + "builtins": [ + "abs", "acos", "actualStream", "asin", "assert", "atan", "atan2", "cardinality", + "ceil", "change", "cos", "cosh", "delay", "div", "edge", "exp", "floor", + "homotopy", "inStream", "integer", "log", "log10", "max", "min", "mod", + "noEvent", "pre", "reinit", "rem", "sample", "semiLinear", "sign", "sin", + "sinh", "smooth", "sqrt", "sum", "tan", "tanh", "terminal", "terminate" + ], + "bevalues": [] +} diff --git a/BEdit/src/bedit/gui/controllers/commands.py b/BEdit/src/bedit/gui/controllers/commands.py index 97c8a08..155a805 100644 --- a/BEdit/src/bedit/gui/controllers/commands.py +++ b/BEdit/src/bedit/gui/controllers/commands.py @@ -252,6 +252,19 @@ class EditComponentPropertiesCommand(QUndoCommand): self.controller._set_component_properties(self.component_id, self.old) +class EditComponentParametersCommand(QUndoCommand): + def __init__(self, controller, component_id: str, old: list, new: list) -> None: + super().__init__("Edit component parameters") + self.controller, self.component_id = controller, component_id + self.old, self.new = old, new + + def redo(self) -> None: + self.controller._set_component_parameters(self.component_id, self.new) + + def undo(self) -> None: + self.controller._set_component_parameters(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") diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index 22d0865..ce12725 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -17,6 +17,7 @@ from bedit.gui.controllers.commands import ( EditTextDefinitionCommand, EditComponentAppearanceCommand, EditComponentPropertiesCommand, + EditComponentParametersCommand, MoveComponentCommand, MoveInterfacePortCommand, PasteSelectionCommand, @@ -166,7 +167,7 @@ class DocumentController(QObject): 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": ""} if kind == "text" else {}, ) self.undo_stack.push(AddComponentCommand(self, None, component)) self.activate_component(component.id) @@ -185,7 +186,7 @@ class DocumentController(QObject): 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": ""} if kind == "text" else {}, ) self.undo_stack.push(AddComponentCommand(self, owner_id, component)) return component.id @@ -628,14 +629,15 @@ class DocumentController(QObject): "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], }, + "parameters": [parameter.to_dict() for parameter in parameters], } if old != new: candidate = deepcopy(self.document) @@ -643,6 +645,7 @@ class DocumentController(QObject): 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)) @@ -810,6 +813,27 @@ class DocumentController(QObject): 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], @@ -1164,6 +1188,17 @@ class DocumentController(QObject): 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, @@ -1239,6 +1274,9 @@ class DocumentController(QObject): 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) diff --git a/BEdit/src/bedit/gui/dialogs/parameter_options.py b/BEdit/src/bedit/gui/dialogs/parameter_options.py new file mode 100644 index 0000000..531ac3e --- /dev/null +++ b/BEdit/src/bedit/gui/dialogs/parameter_options.py @@ -0,0 +1,108 @@ +from copy import deepcopy +from uuid import uuid4 + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDialog, QListWidgetItem, QMessageBox + +from bedit.core.model import Component, Parameter +from bedit.gui.generated.ui_parameter_options_dialog import Ui_ParameterOptionsDialog + + +PARAMETER_ROLE = Qt.ItemDataRole.UserRole + + +class ParameterOptionsDialog(QDialog): + """Editor for parameters shared by graph and text components.""" + + def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None: + super().__init__(parent) + self.ui = Ui_ParameterOptionsDialog() + self.ui.setupUi(self) + self.setWindowTitle(f"Parameter Options — {component.name}") + self.parameters = deepcopy(component.parameters) + self.read_only = read_only + self._loading = False + self.ui.parameterList.currentRowChanged.connect(self._load_current) + self.ui.addParameterButton.clicked.connect(self.add_parameter) + self.ui.removeParameterButton.clicked.connect(self.remove_parameter) + self.ui.nameEdit.textEdited.connect(self._store_current) + self.ui.typeEdit.textEdited.connect(self._store_current) + self.ui.valueEdit.textEdited.connect(self._store_current) + self.ui.parameterSplitter.setSizes([250, 370]) + if read_only: + self.ui.addParameterButton.setEnabled(False) + self.ui.removeParameterButton.setEnabled(False) + self.ui.nameEdit.setReadOnly(True) + self.ui.typeEdit.setReadOnly(True) + self.ui.valueEdit.setReadOnly(True) + self._rebuild_list(0 if self.parameters else -1) + + def _rebuild_list(self, row: int = -1) -> None: + self.ui.parameterList.clear() + for parameter in self.parameters: + item = QListWidgetItem(f"{parameter.name} [{parameter.type}] = {parameter.value}") + item.setData(PARAMETER_ROLE, parameter.id) + self.ui.parameterList.addItem(item) + self.ui.parameterList.setCurrentRow(min(row, len(self.parameters) - 1)) + self._update_enabled() + + def _load_current(self, row: int) -> None: + self._loading = True + if 0 <= row < len(self.parameters): + parameter = self.parameters[row] + self.ui.nameEdit.setText(parameter.name) + self.ui.typeEdit.setText(parameter.type) + self.ui.valueEdit.setText(parameter.value) + else: + self.ui.nameEdit.clear() + self.ui.typeEdit.clear() + self.ui.valueEdit.clear() + self._loading = False + self._update_enabled() + + def _update_enabled(self) -> None: + enabled = self.ui.parameterList.currentRow() >= 0 + self.ui.removeParameterButton.setEnabled(enabled and not self.read_only) + self.ui.nameEdit.setEnabled(enabled) + self.ui.typeEdit.setEnabled(enabled) + self.ui.valueEdit.setEnabled(enabled) + + def _store_current(self) -> None: + row = self.ui.parameterList.currentRow() + if self._loading or not (0 <= row < len(self.parameters)): + return + parameter = self.parameters[row] + parameter.name = self.ui.nameEdit.text() + parameter.type = self.ui.typeEdit.text() + parameter.value = self.ui.valueEdit.text() + self.ui.parameterList.item(row).setText( + f"{parameter.name} [{parameter.type}] = {parameter.value}" + ) + + def add_parameter(self) -> None: + self.parameters.append( + Parameter( + id=f"parameter-{uuid4().hex[:8]}", + name=f"Parameter {len(self.parameters) + 1}", + ) + ) + self._rebuild_list(len(self.parameters) - 1) + self.ui.nameEdit.selectAll() + self.ui.nameEdit.setFocus() + + def remove_parameter(self) -> None: + row = self.ui.parameterList.currentRow() + if row >= 0: + self.parameters.pop(row) + self._rebuild_list(min(row, len(self.parameters) - 1)) + + def accept(self) -> None: + self._store_current() + if any(not parameter.name.strip() for parameter in self.parameters): + QMessageBox.warning(self, "Invalid parameter", "Every parameter needs a name.") + return + names = [parameter.name for parameter in self.parameters] + if len(set(names)) != len(names): + QMessageBox.warning(self, "Invalid parameter", "Parameter names must be unique.") + return + super().accept() diff --git a/BEdit/src/bedit/gui/dialogs/settings.py b/BEdit/src/bedit/gui/dialogs/settings.py index 9592ab1..c1bee8f 100644 --- a/BEdit/src/bedit/gui/dialogs/settings.py +++ b/BEdit/src/bedit/gui/dialogs/settings.py @@ -1,11 +1,13 @@ from pathlib import Path -from PySide6.QtCore import QSettings, Signal -from PySide6.QtWidgets import QDialog, QFileDialog +from PySide6.QtCore import QSettings, Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog, QDialog, QFileDialog, QHeaderView, QTableWidgetItem from bedit.core.libraries import bundled_library_path, default_library_paths from bedit.gui.preferences import application_settings from bedit.gui.generated.ui_settings_dialog import Ui_SettingsDialog +from bedit.gui.editors.openmodelica import HIGHLIGHT_STYLES class SettingsDialog(QDialog): @@ -25,6 +27,7 @@ class SettingsDialog(QDialog): 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.ui.syntaxStylesTable.cellDoubleClicked.connect(self._choose_syntax_color) self._load_settings() def _load_settings(self) -> None: @@ -43,8 +46,47 @@ class SettingsDialog(QDialog): self.ui.graphGridSpinBox.setValue(self.graph_grid_size(self.settings)) self.ui.graphSnapSpinBox.setValue(self.graph_snap_size(self.settings)) self.ui.iconGridSpinBox.setValue(self.icon_grid_size(self.settings)) + self._load_syntax_styles() self._update_remove_button() + def _load_syntax_styles(self) -> None: + table = self.ui.syntaxStylesTable + table.setRowCount(len(HIGHLIGHT_STYLES)) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + for row, (category, defaults) in enumerate(HIGHLIGHT_STYLES.items()): + label, default_color, default_bold, default_italic = defaults + name_item = QTableWidgetItem(label) + name_item.setData(Qt.ItemDataRole.UserRole, category) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + table.setItem(row, 0, name_item) + color = str(self.settings.value(f"syntax/{category}/color", default_color)) + color_item = QTableWidgetItem(color) + color_item.setBackground(QColor(color)) + color_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + table.setItem(row, 1, color_item) + for column, key, default in ( + (2, "bold", default_bold), + (3, "italic", default_italic), + ): + item = QTableWidgetItem() + item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) + enabled = self._as_bool( + self.settings.value(f"syntax/{category}/{key}", default) + ) + item.setCheckState( + Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked + ) + table.setItem(row, column, item) + + def _choose_syntax_color(self, row: int, column: int) -> None: + if column != 1: + return + item = self.ui.syntaxStylesTable.item(row, column) + color = QColorDialog.getColor(QColor(item.text()), self, "Highlight colour") + if color.isValid(): + item.setText(color.name()) + item.setBackground(color) + @staticmethod def _as_bool(value) -> bool: if isinstance(value, str): @@ -134,6 +176,24 @@ class SettingsDialog(QDialog): self.settings.setValue("grid/graphSize", self.ui.graphGridSpinBox.value()) self.settings.setValue("grid/graphSnapSize", self.ui.graphSnapSpinBox.value()) self.settings.setValue("grid/iconSize", self.ui.iconGridSpinBox.value()) + for row in range(self.ui.syntaxStylesTable.rowCount()): + category = self.ui.syntaxStylesTable.item(row, 0).data( + Qt.ItemDataRole.UserRole + ) + self.settings.setValue( + f"syntax/{category}/color", + self.ui.syntaxStylesTable.item(row, 1).text(), + ) + self.settings.setValue( + f"syntax/{category}/bold", + self.ui.syntaxStylesTable.item(row, 2).checkState() + == Qt.CheckState.Checked, + ) + self.settings.setValue( + f"syntax/{category}/italic", + self.ui.syntaxStylesTable.item(row, 3).checkState() + == Qt.CheckState.Checked, + ) self.settings.sync() self.settingsChanged.emit() super().accept() diff --git a/BEdit/src/bedit/gui/editors/openmodelica.py b/BEdit/src/bedit/gui/editors/openmodelica.py new file mode 100644 index 0000000..1d14345 --- /dev/null +++ b/BEdit/src/bedit/gui/editors/openmodelica.py @@ -0,0 +1,252 @@ +import json +from importlib.resources import files + +from PySide6.QtCore import QRegularExpression, QStringListModel, Qt +from PySide6.QtGui import ( + QColor, + QFont, + QKeyEvent, + QSyntaxHighlighter, + QTextCharFormat, + QTextCursor, +) +from PySide6.QtWidgets import QCompleter, QPlainTextEdit + +from bedit.gui.preferences import application_settings + + +HIGHLIGHT_STYLES = { + "keywords": ("Keywords", "#7c3aed", True, False), + "types": ("Types", "#0369a1", True, False), + "builtins": ("Built-ins", "#0f766e", False, False), + "bevalues": ("BEvalues", "#c026d3", True, False), + "inputs": ("Inputs", "#1d4ed8", False, False), + "outputs": ("Outputs", "#be123c", False, False), + "parameters": ("Parameters", "#a16207", False, False), + "numbers": ("Numbers", "#b45309", False, False), + "strings": ("Strings", "#15803d", False, False), + "comments": ("Comments", "#6b7280", False, True), +} + + +def load_openmodelica_syntax() -> dict[str, list[str]]: + resource = files("bedit").joinpath("data/syntax/openmodelica.json") + with resource.open(encoding="utf-8") as file: + values = json.load(file) + return { + category: [str(word) for word in values.get(category, [])] + for category in ("keywords", "types", "builtins", "bevalues") + } + + +def _format(color: str, *, bold: bool = False, italic: bool = False) -> QTextCharFormat: + value = QTextCharFormat() + value.setForeground(QColor(color)) + value.setFontWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal) + value.setFontItalic(italic) + return value + + +def _as_bool(value) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def highlighting_style(category: str) -> tuple[str, bool, bool]: + _label, default_color, default_bold, default_italic = HIGHLIGHT_STYLES[category] + settings = application_settings() + prefix = f"syntax/{category}" + return ( + str(settings.value(f"{prefix}/color", default_color)), + _as_bool(settings.value(f"{prefix}/bold", default_bold)), + _as_bool(settings.value(f"{prefix}/italic", default_italic)), + ) + + +class OpenModelicaHighlighter(QSyntaxHighlighter): + """Syntax highlighter driven by the editable OpenModelica word list.""" + + def __init__( + self, + document, + syntax: dict[str, list[str]], + symbols: dict[str, list[str]], + ) -> None: + super().__init__(document) + self.rules: list[tuple[QRegularExpression, QTextCharFormat]] = [] + for category in ( + "keywords", + "types", + "builtins", + "inputs", + "outputs", + "parameters", + ): + words = syntax.get(category, symbols.get(category, [])) + if words: + pattern = r"\b(?:" + "|".join(map(QRegularExpression.escape, words)) + r")\b" + color, bold, italic = highlighting_style(category) + self.rules.append( + (QRegularExpression(pattern), _format(color, bold=bold, italic=italic)) + ) + bevalue_style = highlighting_style("bevalues") + number_style = highlighting_style("numbers") + string_style = highlighting_style("strings") + self.rules.extend( + [ + ( + QRegularExpression(r"\$[A-Za-z_][A-Za-z0-9_]*\$"), + _format( + bevalue_style[0], + bold=bevalue_style[1], + italic=bevalue_style[2], + ), + ), + ( + QRegularExpression(r"\b(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b"), + _format(number_style[0], bold=number_style[1], italic=number_style[2]), + ), + ( + QRegularExpression(r'"(?:\\.|[^"\\])*"'), + _format(string_style[0], bold=string_style[1], italic=string_style[2]), + ), + ] + ) + comment_style = highlighting_style("comments") + self.comment_format = _format( + comment_style[0], bold=comment_style[1], italic=comment_style[2] + ) + self.rules.append((QRegularExpression(r"//.*$"), self.comment_format)) + self.comment_start = QRegularExpression(r"/\*") + self.comment_end = QRegularExpression(r"\*/") + + def highlightBlock(self, text: str) -> None: # noqa: N802 + for expression, text_format in self.rules: + match = expression.globalMatch(text) + while match.hasNext(): + result = match.next() + self.setFormat(result.capturedStart(), result.capturedLength(), text_format) + + self.setCurrentBlockState(0) + start = ( + 0 + if self.previousBlockState() == 1 + else self.comment_start.match(text).capturedStart() + ) + while start >= 0: + end_match = self.comment_end.match(text, start + 2) + if end_match.hasMatch(): + length = end_match.capturedEnd() - start + else: + self.setCurrentBlockState(1) + length = len(text) - start + self.setFormat(start, length, self.comment_format) + if not end_match.hasMatch(): + break + start = self.comment_start.match(text, start + length).capturedStart() + + +class OpenModelicaEditor(QPlainTextEdit): + """OpenModelica text editor with syntax highlighting and completion.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.setPlaceholderText("Enter OpenModelica equations here…") + self.syntax = load_openmodelica_syntax() + self.symbols = {"inputs": [], "outputs": [], "parameters": []} + self.highlighter = OpenModelicaHighlighter( + self.document(), self.syntax, self.symbols + ) + self.completer = QCompleter(self) + self.completer.setWidget(self) + self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.completer.activated.connect(self._insert_completion) + self._rebuild_completions() + + def set_symbols( + self, + inputs: list[str], + outputs: list[str], + parameters: list[str], + ) -> None: + self.symbols = { + "inputs": [name for name in inputs if name], + "outputs": [name for name in outputs if name], + "parameters": [name for name in parameters if name], + } + self.reload_highlighting() + + def reload_highlighting(self) -> None: + self.highlighter.setDocument(None) + self.highlighter = OpenModelicaHighlighter( + self.document(), self.syntax, self.symbols + ) + self._rebuild_completions() + + def _rebuild_completions(self) -> None: + words = sorted( + { + *self.syntax["keywords"], + *self.syntax["types"], + *self.syntax["builtins"], + *(f"${name.strip('$')}$" for name in self.syntax["bevalues"]), + *self.symbols["inputs"], + *self.symbols["outputs"], + *self.symbols["parameters"], + }, + key=str.casefold, + ) + self.completer.setModel(QStringListModel(words, self.completer)) + + def _completion_prefix(self) -> str: + cursor = self.textCursor() + text = cursor.block().text()[: cursor.positionInBlock()] + index = len(text) + while index > 0 and (text[index - 1].isalnum() or text[index - 1] in "_$"): + index -= 1 + return text[index:] + + def _insert_completion(self, completion: str) -> None: + prefix = self._completion_prefix() + cursor = self.textCursor() + cursor.movePosition( + QTextCursor.MoveOperation.Left, + QTextCursor.MoveMode.KeepAnchor, + len(prefix), + ) + cursor.insertText(completion) + self.setTextCursor(cursor) + + def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 + popup = self.completer.popup() + if popup.isVisible() and event.key() in { + Qt.Key.Key_Enter, + Qt.Key.Key_Return, + Qt.Key.Key_Escape, + Qt.Key.Key_Tab, + Qt.Key.Key_Backtab, + }: + event.ignore() + return + explicit = ( + event.modifiers() == Qt.KeyboardModifier.ControlModifier + and event.key() == Qt.Key.Key_Space + ) + if not explicit: + super().keyPressEvent(event) + prefix = self._completion_prefix() + if not explicit and (len(prefix) < 2 or event.text() == ""): + popup.hide() + return + self.completer.setCompletionPrefix(prefix) + if self.completer.completionCount() == 0: + popup.hide() + return + rectangle = self.cursorRect() + rectangle.setWidth( + popup.sizeHintForColumn(0) + popup.verticalScrollBar().sizeHint().width() + ) + self.completer.complete(rectangle) diff --git a/BEdit/src/bedit/gui/editors/text_definition.py b/BEdit/src/bedit/gui/editors/text_definition.py index 6493478..b688651 100644 --- a/BEdit/src/bedit/gui/editors/text_definition.py +++ b/BEdit/src/bedit/gui/editors/text_definition.py @@ -34,8 +34,8 @@ class TextDefinitionEditor(QWidget): 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.portsTable.cellChanged.connect(self._symbols_modified) + self.ui.parametersTable.cellChanged.connect(self._symbols_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) @@ -102,6 +102,11 @@ class TextDefinitionEditor(QWidget): self.ui.parametersTable.setRowCount(0) for parameter in parameters: self._append_parameter(parameter) + self.ui.equationsEdit.set_symbols( + [port.name for port in inputs], + [port.name for port in outputs], + [parameter.name for parameter in parameters], + ) self._loading = False self.set_modified(False) self._update_buttons() @@ -116,12 +121,25 @@ class TextDefinitionEditor(QWidget): self.set_modified(True) self.definitionEdited.emit() + def _symbols_modified(self, *_args) -> None: + if not self._loading: + self._refresh_editor_symbols() + self._mark_modified() + + def _refresh_editor_symbols(self) -> None: + inputs, outputs = self.ports + self.ui.equationsEdit.set_symbols( + [port.name for port in inputs], + [port.name for port in outputs], + [parameter.name for parameter in self.parameters], + ) + 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) + combo.currentIndexChanged.connect(self._symbols_modified) return combo def _append_port(self, port: Port, orientation: str) -> None: @@ -141,7 +159,7 @@ class TextDefinitionEditor(QWidget): ) multiple = QCheckBox("Any", self) multiple.setChecked(port.allows_multiple_connections) - multiple.toggled.connect(self._mark_modified) + multiple.toggled.connect(self._symbols_modified) table.setCellWidget(row, 3, multiple) def _append_parameter(self, parameter: Parameter) -> None: @@ -165,13 +183,13 @@ class TextDefinitionEditor(QWidget): self._append_port(port, "input") self._loading = False self.ui.portsTable.selectRow(self.ui.portsTable.rowCount() - 1) - self._mark_modified() + self._symbols_modified() def remove_port(self) -> None: row = self.ui.portsTable.currentRow() if row >= 0: self.ui.portsTable.removeRow(row) - self._mark_modified() + self._symbols_modified() self._update_buttons() def add_parameter(self) -> None: @@ -183,13 +201,13 @@ class TextDefinitionEditor(QWidget): self._append_parameter(parameter) self._loading = False self.ui.parametersTable.selectRow(self.ui.parametersTable.rowCount() - 1) - self._mark_modified() + self._symbols_modified() def remove_parameter(self) -> None: row = self.ui.parametersTable.currentRow() if row >= 0: self.ui.parametersTable.removeRow(row) - self._mark_modified() + self._symbols_modified() self._update_buttons() def _update_buttons(self) -> None: diff --git a/BEdit/src/bedit/gui/generated/ui_main_window.py b/BEdit/src/bedit/gui/generated/ui_main_window.py index c343660..c0eae2d 100644 --- a/BEdit/src/bedit/gui/generated/ui_main_window.py +++ b/BEdit/src/bedit/gui/generated/ui_main_window.py @@ -73,6 +73,8 @@ class Ui_MainWindow(object): self.actionOpen.setIcon(icon7) self.actionReloadLibraries = QAction(MainWindow) self.actionReloadLibraries.setObjectName(u"actionReloadLibraries") + self.actionReloadSimulation = QAction(MainWindow) + self.actionReloadSimulation.setObjectName(u"actionReloadSimulation") self.actionSave = QAction(MainWindow) self.actionSave.setObjectName(u"actionSave") icon8 = QIcon() @@ -397,6 +399,7 @@ class Ui_MainWindow(object): self.menuFile.addAction(self.actionNew) self.menuFile.addAction(self.actionOpen) self.menuFile.addAction(self.actionReloadLibraries) + self.menuFile.addAction(self.actionReloadSimulation) self.menuFile.addSeparator() self.menuFile.addAction(self.actionSave) self.menuFile.addAction(self.actionSaveAs) @@ -450,6 +453,9 @@ class Ui_MainWindow(object): #if QT_CONFIG(statustip) self.actionCompile.setStatusTip(QCoreApplication.translate("MainWindow", u"Compile the active graph for simulation", None)) #endif // QT_CONFIG(statustip) +#if QT_CONFIG(shortcut) + self.actionCompile.setShortcut(QCoreApplication.translate("MainWindow", u"F5", None)) +#endif // QT_CONFIG(shortcut) self.actionNew.setText(QCoreApplication.translate("MainWindow", u"&New", None)) #if QT_CONFIG(statustip) self.actionNew.setStatusTip(QCoreApplication.translate("MainWindow", u"Create a new document", None)) @@ -488,7 +494,14 @@ class Ui_MainWindow(object): 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)) + self.actionReloadLibraries.setShortcut(QCoreApplication.translate("MainWindow", u"Shift+F5", None)) +#endif // QT_CONFIG(shortcut) + self.actionReloadSimulation.setText(QCoreApplication.translate("MainWindow", u"Reload &Simulation Code", None)) +#if QT_CONFIG(statustip) + self.actionReloadSimulation.setStatusTip(QCoreApplication.translate("MainWindow", u"Reload the simulation package while preserving runtime state", None)) +#endif // QT_CONFIG(statustip) +#if QT_CONFIG(shortcut) + self.actionReloadSimulation.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+F5", None)) #endif // QT_CONFIG(shortcut) self.actionSave.setText(QCoreApplication.translate("MainWindow", u"&Save", None)) #if QT_CONFIG(statustip) diff --git a/BEdit/src/bedit/gui/generated/ui_parameter_options_dialog.py b/BEdit/src/bedit/gui/generated/ui_parameter_options_dialog.py new file mode 100644 index 0000000..1a3ad79 --- /dev/null +++ b/BEdit/src/bedit/gui/generated/ui_parameter_options_dialog.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'parameter_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, QDialog, QDialogButtonBox, + QFormLayout, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QListWidgetItem, QPushButton, QSizePolicy, + QSplitter, QVBoxLayout, QWidget) + +class Ui_ParameterOptionsDialog(object): + def setupUi(self, ParameterOptionsDialog): + if not ParameterOptionsDialog.objectName(): + ParameterOptionsDialog.setObjectName(u"ParameterOptionsDialog") + ParameterOptionsDialog.resize(620, 380) + self.dialogLayout = QVBoxLayout(ParameterOptionsDialog) + self.dialogLayout.setObjectName(u"dialogLayout") + self.parameterSplitter = QSplitter(ParameterOptionsDialog) + self.parameterSplitter.setObjectName(u"parameterSplitter") + self.parameterSplitter.setOrientation(Qt.Orientation.Horizontal) + self.parameterListPanel = QWidget(self.parameterSplitter) + self.parameterListPanel.setObjectName(u"parameterListPanel") + self.parameterListLayout = QVBoxLayout(self.parameterListPanel) + self.parameterListLayout.setObjectName(u"parameterListLayout") + self.parameterListLayout.setContentsMargins(0, 0, 0, 0) + self.parameterList = QListWidget(self.parameterListPanel) + self.parameterList.setObjectName(u"parameterList") + + self.parameterListLayout.addWidget(self.parameterList) + + self.parameterButtonsLayout = QHBoxLayout() + self.parameterButtonsLayout.setObjectName(u"parameterButtonsLayout") + self.addParameterButton = QPushButton(self.parameterListPanel) + self.addParameterButton.setObjectName(u"addParameterButton") + + self.parameterButtonsLayout.addWidget(self.addParameterButton) + + self.removeParameterButton = QPushButton(self.parameterListPanel) + self.removeParameterButton.setObjectName(u"removeParameterButton") + + self.parameterButtonsLayout.addWidget(self.removeParameterButton) + + + self.parameterListLayout.addLayout(self.parameterButtonsLayout) + + self.parameterSplitter.addWidget(self.parameterListPanel) + self.parameterDetailsPanel = QWidget(self.parameterSplitter) + self.parameterDetailsPanel.setObjectName(u"parameterDetailsPanel") + self.parameterDetailsForm = QFormLayout(self.parameterDetailsPanel) + self.parameterDetailsForm.setObjectName(u"parameterDetailsForm") + self.parameterDetailsForm.setContentsMargins(0, 0, 0, 0) + self.nameLabel = QLabel(self.parameterDetailsPanel) + self.nameLabel.setObjectName(u"nameLabel") + + self.parameterDetailsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel) + + self.nameEdit = QLineEdit(self.parameterDetailsPanel) + self.nameEdit.setObjectName(u"nameEdit") + + self.parameterDetailsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit) + + self.typeLabel = QLabel(self.parameterDetailsPanel) + self.typeLabel.setObjectName(u"typeLabel") + + self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel) + + self.typeEdit = QLineEdit(self.parameterDetailsPanel) + self.typeEdit.setObjectName(u"typeEdit") + + self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.typeEdit) + + self.valueLabel = QLabel(self.parameterDetailsPanel) + self.valueLabel.setObjectName(u"valueLabel") + + self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.valueLabel) + + self.valueEdit = QLineEdit(self.parameterDetailsPanel) + self.valueEdit.setObjectName(u"valueEdit") + + self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.valueEdit) + + self.parameterSplitter.addWidget(self.parameterDetailsPanel) + + self.dialogLayout.addWidget(self.parameterSplitter) + + self.buttonBox = QDialogButtonBox(ParameterOptionsDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) + + self.dialogLayout.addWidget(self.buttonBox) + + + self.retranslateUi(ParameterOptionsDialog) + self.buttonBox.accepted.connect(ParameterOptionsDialog.accept) + self.buttonBox.rejected.connect(ParameterOptionsDialog.reject) + + QMetaObject.connectSlotsByName(ParameterOptionsDialog) + # setupUi + + def retranslateUi(self, ParameterOptionsDialog): + ParameterOptionsDialog.setWindowTitle(QCoreApplication.translate("ParameterOptionsDialog", u"Parameter Options", None)) + self.addParameterButton.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Add Parameter", None)) + self.removeParameterButton.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Remove Parameter", None)) + self.nameLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Name:", None)) + self.typeLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Type:", None)) + self.valueLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Value:", None)) + # retranslateUi + diff --git a/BEdit/src/bedit/gui/generated/ui_settings_dialog.py b/BEdit/src/bedit/gui/generated/ui_settings_dialog.py index 9427d28..7e276a1 100644 --- a/BEdit/src/bedit/gui/generated/ui_settings_dialog.py +++ b/BEdit/src/bedit/gui/generated/ui_settings_dialog.py @@ -15,17 +15,18 @@ 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, QDialog, QDialogButtonBox, - QFormLayout, QGroupBox, QHBoxLayout, QLabel, - QListWidget, QListWidgetItem, QPushButton, QSizePolicy, - QSpacerItem, QSpinBox, QTabWidget, QVBoxLayout, +from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QDialog, + QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QListWidget, QListWidgetItem, + QPushButton, QSizePolicy, QSpacerItem, QSpinBox, + QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) class Ui_SettingsDialog(object): def setupUi(self, SettingsDialog): if not SettingsDialog.objectName(): SettingsDialog.setObjectName(u"SettingsDialog") - SettingsDialog.resize(480, 420) + SettingsDialog.resize(480, 520) SettingsDialog.setModal(True) self.dialogLayout = QVBoxLayout(SettingsDialog) self.dialogLayout.setObjectName(u"dialogLayout") @@ -103,6 +104,34 @@ class Ui_SettingsDialog(object): self.generalLayout.addItem(self.generalSpacer) self.settingsTabs.addTab(self.generalTab, "") + self.syntaxTab = QWidget() + self.syntaxTab.setObjectName(u"syntaxTab") + self.syntaxTabLayout = QVBoxLayout(self.syntaxTab) + self.syntaxTabLayout.setObjectName(u"syntaxTabLayout") + self.syntaxHintLabel = QLabel(self.syntaxTab) + self.syntaxHintLabel.setObjectName(u"syntaxHintLabel") + self.syntaxHintLabel.setWordWrap(True) + + self.syntaxTabLayout.addWidget(self.syntaxHintLabel) + + self.syntaxStylesTable = QTableWidget(self.syntaxTab) + if (self.syntaxStylesTable.columnCount() < 4): + self.syntaxStylesTable.setColumnCount(4) + __qtablewidgetitem = QTableWidgetItem() + self.syntaxStylesTable.setHorizontalHeaderItem(0, __qtablewidgetitem) + __qtablewidgetitem1 = QTableWidgetItem() + self.syntaxStylesTable.setHorizontalHeaderItem(1, __qtablewidgetitem1) + __qtablewidgetitem2 = QTableWidgetItem() + self.syntaxStylesTable.setHorizontalHeaderItem(2, __qtablewidgetitem2) + __qtablewidgetitem3 = QTableWidgetItem() + self.syntaxStylesTable.setHorizontalHeaderItem(3, __qtablewidgetitem3) + self.syntaxStylesTable.setObjectName(u"syntaxStylesTable") + self.syntaxStylesTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.syntaxStylesTable.setColumnCount(4) + + self.syntaxTabLayout.addWidget(self.syntaxStylesTable) + + self.settingsTabs.addTab(self.syntaxTab, "") self.librariesTab = QWidget() self.librariesTab.setObjectName(u"librariesTab") self.librariesTabLayout = QVBoxLayout(self.librariesTab) @@ -176,6 +205,16 @@ class Ui_SettingsDialog(object): self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None)) self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None)) self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None)) + self.syntaxHintLabel.setText(QCoreApplication.translate("SettingsDialog", u"Double-click a colour cell to choose a colour. Word lists remain editable in data/syntax/openmodelica.json.", None)) + ___qtablewidgetitem = self.syntaxStylesTable.horizontalHeaderItem(0) + ___qtablewidgetitem.setText(QCoreApplication.translate("SettingsDialog", u"Expression type", None)) + ___qtablewidgetitem1 = self.syntaxStylesTable.horizontalHeaderItem(1) + ___qtablewidgetitem1.setText(QCoreApplication.translate("SettingsDialog", u"Colour", None)) + ___qtablewidgetitem2 = self.syntaxStylesTable.horizontalHeaderItem(2) + ___qtablewidgetitem2.setText(QCoreApplication.translate("SettingsDialog", u"Bold", None)) + ___qtablewidgetitem3 = self.syntaxStylesTable.horizontalHeaderItem(3) + ___qtablewidgetitem3.setText(QCoreApplication.translate("SettingsDialog", u"Italic", None)) + self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.syntaxTab), QCoreApplication.translate("SettingsDialog", u"Text highlighting", None)) self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON or BEdit Binary 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)) diff --git a/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py b/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py index 4c23d7a..e5a5350 100644 --- a/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py +++ b/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py @@ -16,9 +16,11 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 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) + QHeaderView, QPushButton, QSizePolicy, QSpacerItem, + QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, + QWidget) + +from bedit.gui.editors.openmodelica import OpenModelicaEditor class Ui_TextDefinitionEditor(object): def setupUi(self, TextDefinitionEditor): @@ -36,9 +38,8 @@ class Ui_TextDefinitionEditor(object): self.equationsGroup.setObjectName(u"equationsGroup") self.equationsLayout = QVBoxLayout(self.equationsGroup) self.equationsLayout.setObjectName(u"equationsLayout") - self.equationsEdit = QPlainTextEdit(self.equationsGroup) + self.equationsEdit = OpenModelicaEditor(self.equationsGroup) self.equationsEdit.setObjectName(u"equationsEdit") - self.equationsEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self.equationsLayout.addWidget(self.equationsEdit) @@ -141,7 +142,6 @@ class Ui_TextDefinitionEditor(object): 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)) diff --git a/BEdit/src/bedit/gui/graphics/workspace.py b/BEdit/src/bedit/gui/graphics/workspace.py index 7e1dea8..222653c 100644 --- a/BEdit/src/bedit/gui/graphics/workspace.py +++ b/BEdit/src/bedit/gui/graphics/workspace.py @@ -252,6 +252,7 @@ class ComponentGraphicsItem(QGraphicsObject): menu = QMenu() options_action = menu.addAction("Component Options…") ports_action = menu.addAction("Port Options…") + parameters_action = menu.addAction("Parameter Options…") selected = menu.exec(event.screenPos()) if selected is options_action: scene = self.scene() @@ -261,6 +262,10 @@ class ComponentGraphicsItem(QGraphicsObject): scene = self.scene() if isinstance(scene, GraphScene): scene.componentPortOptionsRequested.emit(self.component_id) + elif selected is parameters_action: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.componentParameterOptionsRequested.emit(self.component_id) event.accept() def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 @@ -832,6 +837,7 @@ class LineAnnotationGraphicsItem(QGraphicsPathItem): class GraphScene(QGraphicsScene): componentOptionsRequested = Signal(str) componentPortOptionsRequested = Signal(str) + componentParameterOptionsRequested = Signal(str) portOptionsRequested = Signal(str, str) connectionOptionsRequested = Signal(str) @@ -1694,6 +1700,7 @@ class GraphWorkspaceView(QGraphicsView): toolModeShortcutRequested = Signal(str) componentOptionsRequested = Signal(str) componentPortOptionsRequested = Signal(str) + componentParameterOptionsRequested = Signal(str) portOptionsRequested = Signal(str, str) connectionOptionsRequested = Signal(str) selectionAvailabilityChanged = Signal(bool) @@ -1779,6 +1786,9 @@ class GraphWorkspaceView(QGraphicsView): scene = GraphScene(controller, self) scene.componentOptionsRequested.connect(self.componentOptionsRequested) scene.componentPortOptionsRequested.connect(self.componentPortOptionsRequested) + scene.componentParameterOptionsRequested.connect( + self.componentParameterOptionsRequested + ) scene.portOptionsRequested.connect(self.portOptionsRequested) scene.connectionOptionsRequested.connect(self.connectionOptionsRequested) scene.selectionChanged.connect( diff --git a/BEdit/src/bedit/gui/main_window.py b/BEdit/src/bedit/gui/main_window.py index 844ef74..95ad7de 100644 --- a/BEdit/src/bedit/gui/main_window.py +++ b/BEdit/src/bedit/gui/main_window.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import ( QTabWidget, ) -from bedit.core.model import Component, Parameter +from bedit.core.model import Component from bedit.core.application_log import get_logger from bedit.core.serializer import DocumentSerializer from bedit.core.simulation import Simulation @@ -29,8 +29,10 @@ from bedit.gui.models.library_tree import ( ) from bedit.gui.dialogs.settings import SettingsDialog from bedit.gui.dialogs.port_options import PortOptionsDialog +from bedit.gui.dialogs.parameter_options import ParameterOptionsDialog from bedit.gui.dialogs.simulation_settings import SimulationSettingsDialog from bedit.gui.preferences import application_settings +from bedit.gui.simulation_reload import reload_simulation from bedit.gui.generated.ui_main_window import Ui_MainWindow from bedit.gui.application_log import ApplicationLogHandler @@ -99,6 +101,9 @@ class MainWindow(QMainWindow): self.ui.graphView.set_model(self.document_controller) self.ui.graphView.componentOptionsRequested.connect(self.show_component_options) self.ui.graphView.componentPortOptionsRequested.connect(self.show_component_port_options) + self.ui.graphView.componentParameterOptionsRequested.connect( + self.show_component_parameter_options + ) self.ui.graphView.portOptionsRequested.connect(self.show_port_options) self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options) self.ui.graphView.selectionAvailabilityChanged.connect( @@ -132,6 +137,7 @@ class MainWindow(QMainWindow): 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.actionReloadSimulation.triggered.connect(self.reload_simulation_code) 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) @@ -191,6 +197,18 @@ class MainWindow(QMainWindow): "\n".join(self.libraries.load_warnings), ) + @Slot() + def reload_simulation_code(self) -> None: + try: + replacement = reload_simulation(self.simulation) + except Exception as error: + self.log.exception("Could not reload simulation code") + QMessageBox.critical(self, "Could not reload simulation code", str(error)) + return + self.simulation = replacement + self.document_controller.simulation = replacement + self.log.info("Reloaded simulation code") + @Slot() def show_simulation_settings(self) -> None: component = self.document_controller.active_component @@ -314,10 +332,7 @@ class MainWindow(QMainWindow): component.source.get("equations", ""), component.inputs, component.outputs, - [ - Parameter.from_dict(parameter) - for parameter in component.source.get("parameters", []) - ], + component.parameters, ) def _resolve_source_edits(self) -> bool: @@ -475,6 +490,7 @@ class MainWindow(QMainWindow): if scene is not None: scene.update() self.ui.graphView.viewport().update() + self.ui.textDefinitionEditor.ui.equationsEdit.reload_highlighting() def _document_opened_changed(self, opened: bool) -> None: for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs): @@ -509,6 +525,7 @@ class MainWindow(QMainWindow): menu.addSeparator() options_action = menu.addAction("Component Options…") ports_action = menu.addAction("Port Options…") + parameters_action = menu.addAction("Parameter Options…") delete_action = menu.addAction("Delete") selected = menu.exec(tree_view.viewport().mapToGlobal(position)) if graph_action is not None and selected is graph_action: @@ -519,6 +536,8 @@ class MainWindow(QMainWindow): self.show_component_options(component_id) elif selected is ports_action: self.show_component_port_options(component_id) + elif selected is parameters_action: + self.show_component_parameter_options(component_id) elif selected is delete_action: answer = QMessageBox.question( self, @@ -549,7 +568,9 @@ class MainWindow(QMainWindow): return menu = QMenu(self) ports_action = menu.addAction("Port Options…") - if menu.exec(tree.viewport().mapToGlobal(position)) is ports_action: + parameters_action = menu.addAction("Parameter Options…") + selected = menu.exec(tree.viewport().mapToGlobal(position)) + if selected is ports_action: dialog = PortOptionsDialog(component, self) if dialog.exec() == dialog.DialogCode.Accepted: old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs) @@ -572,6 +593,32 @@ class MainWindow(QMainWindow): self.log.error("Could not change library ports: %s", error) QMessageBox.warning(self, "Cannot change library ports", str(error)) self.library_tree_model.rebuild() + elif selected is parameters_action: + dialog = ParameterOptionsDialog(component, self) + if dialog.exec() == dialog.DialogCode.Accepted: + old_parameters = deepcopy(component.parameters) + component.parameters = dialog.parameters + library = next( + ( + library + for library in self.libraries.libraries + if any(item is component for item in library.document.all_components()) + ), + None, + ) + try: + if library is not None: + library.document.validate() + DocumentSerializer.save( + library.document, Path(library.source_path) + ) + except (OSError, ValueError) as error: + component.parameters = old_parameters + self.log.error("Could not change library parameters: %s", error) + QMessageBox.warning( + self, "Cannot change library parameters", str(error) + ) + self.library_tree_model.rebuild() @Slot(str) def show_component_port_options(self, component_id: str) -> None: @@ -590,6 +637,23 @@ class MainWindow(QMainWindow): self.log.error("Could not change component ports: %s", error) QMessageBox.warning(self, "Cannot change ports", str(error)) + @Slot(str) + def show_component_parameter_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 = ParameterOptionsDialog(component, self) + if dialog.exec() != dialog.DialogCode.Accepted: + return + try: + self.document_controller.edit_component_parameters( + component_id, dialog.parameters + ) + except ValueError as error: + self.log.error("Could not change component parameters: %s", error) + QMessageBox.warning(self, "Cannot change parameters", str(error)) + @Slot(str) def show_component_options(self, component_id: str) -> None: document = self.document_controller.document diff --git a/BEdit/src/bedit/gui/simulation_reload.py b/BEdit/src/bedit/gui/simulation_reload.py new file mode 100644 index 0000000..7d740ed --- /dev/null +++ b/BEdit/src/bedit/gui/simulation_reload.py @@ -0,0 +1,33 @@ +from copy import deepcopy +import importlib +import pkgutil +from types import ModuleType + + +SIMULATION_PACKAGE = "bedit.core.simulation" + + +def _saved_instance_state(instance) -> dict: + state = {} + for name, value in vars(instance).items(): + try: + state[name] = deepcopy(value) + except Exception: + state[name] = value + return state + + +def reload_simulation(current): + """Reload the simulation package and return a fresh state-preserving instance.""" + saved_state = _saved_instance_state(current) + importlib.invalidate_caches() + package = importlib.import_module(SIMULATION_PACKAGE) + discovered: list[ModuleType] = [] + for module_info in pkgutil.walk_packages(package.__path__, f"{SIMULATION_PACKAGE}."): + discovered.append(importlib.import_module(module_info.name)) + for module in sorted(discovered, key=lambda item: item.__name__.count("."), reverse=True): + importlib.reload(module) + package = importlib.reload(package) + replacement = package.Simulation() + vars(replacement).update(saved_state) + return replacement diff --git a/BEdit/ui/main_window.ui b/BEdit/ui/main_window.ui index 4ba5f72..13c6069 100644 --- a/BEdit/ui/main_window.ui +++ b/BEdit/ui/main_window.ui @@ -365,81 +365,112 @@ - Qt::Orientation::Vertical - false - - - 0 + + Qt::Orientation::Vertical - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - background-color: #9a9a9a; + + false + + + + 0 - - - - - background: transparent; color: #202020; - - - No document open - - - Qt::AlignmentFlag::AlignCenter - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + background-color: #9a9a9a; + + + + + + background: transparent; color: #202020; + + + No document open + + + Qt::AlignmentFlag::AlignCenter + + + + + - 0100 + + + 0 + 100 + + - Log + + Log + - 0 - 0 - 0 - 0 - true5000Application messages appear here. + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + 5000 + + + Application messages appear here. + + + @@ -467,6 +498,7 @@ + @@ -626,6 +658,9 @@ Compile the active graph for simulation + + F5 + @@ -716,7 +751,18 @@ Reload configured library files from disk - F5 + Shift+F5 + + + + + Reload &Simulation Code + + + Reload the simulation package while preserving runtime state + + + Ctrl+F5 diff --git a/BEdit/ui/parameter_options_dialog.ui b/BEdit/ui/parameter_options_dialog.ui new file mode 100644 index 0000000..a16542a --- /dev/null +++ b/BEdit/ui/parameter_options_dialog.ui @@ -0,0 +1,14 @@ + + + ParameterOptionsDialog + + 00620380 + Parameter Options + + Qt::Orientation::HorizontalAdd ParameterRemove ParameterName:Type:Value: + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + buttonBoxaccepted()ParameterOptionsDialogaccept()buttonBoxrejected()ParameterOptionsDialogreject() + diff --git a/BEdit/ui/settings_dialog.ui b/BEdit/ui/settings_dialog.ui index 1625988..80544b8 100644 --- a/BEdit/ui/settings_dialog.ui +++ b/BEdit/ui/settings_dialog.ui @@ -7,7 +7,7 @@ 0 0 480 - 420 + 520 @@ -86,6 +86,13 @@ + + Text highlighting + + Double-click a colour cell to choose a colour. Word lists remain editable in data/syntax/openmodelica.json.true + QAbstractItemView::SelectionBehavior::SelectRows4Expression typeColourBoldItalic + + Libraries diff --git a/BEdit/ui/text_definition_editor.ui b/BEdit/ui/text_definition_editor.ui index 3342287..5fe9fdd 100644 --- a/BEdit/ui/text_definition_editor.ui +++ b/BEdit/ui/text_definition_editor.ui @@ -15,7 +15,7 @@ Equations - QPlainTextEdit::LineWrapMode::NoWrapEnter equations here… + @@ -40,6 +40,13 @@ + + + OpenModelicaEditor + QPlainTextEdit +
bedit.gui.editors.openmodelica
+
+
diff --git a/BEdit/untitled.bedit.json b/BEdit/untitled.bedit.json index 6c56f8c..5cb4af8 100644 --- a/BEdit/untitled.bedit.json +++ b/BEdit/untitled.bedit.json @@ -6,51 +6,18 @@ }, "roots": [ { - "id": "57fe8127-ea9d-4a8b-8d09-da430ffbbd92", - "name": "PID", + "id": "6060fdd6-e816-4da9-8bb6-fde1a66d3e92", + "name": "Test", "position": { "x": 0.0, "y": 0.0 }, "rotation": 0.0, "interface": { - "inputs": [ - { - "id": "port-1342716d", - "name": "in", - "position": { - "x": -384.0, - "y": -112.0 - }, - "properties": { - "iconPosition": { - "x": 0.0, - "y": 0.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], - "outputs": [ - { - "id": "port-c75d0f16", - "name": "out", - "position": { - "x": 320.0, - "y": -112.0 - }, - "properties": { - "iconPosition": { - "x": 0.0, - "y": 0.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] + "inputs": [], + "outputs": [] }, + "parameters": [], "icon": { "shape": "rectangle", "fill": "#f4f4f4", @@ -89,17 +56,124 @@ "showName": false }, "library": { - "showSubtree": false + "showSubtree": true }, "implementation": { "kind": "graph", "graph": { "blocks": [ { - "id": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8", - "name": "g_D", + "id": "2e6a192c-5aeb-45d6-80ee-d64a72392b76", + "name": "Constant0", "position": { - "x": -128.0, + "x": -160.0, + "y": -160.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [], + "outputs": [ + { + "id": "port-ddc97277", + "name": "y", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "parameters": [ + { + "id": "parameter-022177fc", + "name": "v", + "type": "real", + "value": "1" + } + ], + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "cornerRadius": 5.0, + "fill": "#f4f4f4", + "height": 64.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "rectangle", + "width": 64.0, + "x": 32.0, + "y": 32.0 + }, + { + "fill": "none", + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "type": "line", + "width": 0.0, + "x": 40.0, + "y": 40.0 + }, + { + "fill": "none", + "height": 0.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "type": "line", + "width": 48.0, + "x": 40.0, + "y": 88.0 + }, + { + "fill": "none", + "height": 0.0, + "lineStyle": "solid", + "lineWidth": 1.0, + "stroke": "#ffaa00", + "type": "line", + "width": 48.0, + "x": 40.0, + "y": 64.0 + } + ] + }, + "properties": { + "showName": true + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "y = v;" + } + } + }, + { + "id": "f4a78255-8b73-4bb3-ae8e-24b566c72bc0", + "name": "gain0", + "position": { + "x": -32.0, "y": -160.0 }, "rotation": 0.0, @@ -107,7 +181,7 @@ "inputs": [ { "id": "port-df34ce84", - "name": "in", + "name": "u", "position": { "x": 0.0, "y": 0.0 @@ -125,7 +199,7 @@ "outputs": [ { "id": "port-fe9e6486", - "name": "out", + "name": "y", "position": { "x": 0.0, "y": 0.0 @@ -141,6 +215,14 @@ } ] }, + "parameters": [ + { + "id": "parameter-61a43a86", + "name": "k", + "type": "real", + "value": "2.5" + } + ], "icon": { "shape": "rectangle", "fill": "#f4f4f4", @@ -188,49 +270,24 @@ "implementation": { "kind": "text", "source": { - "equations": "out = k*in;", - "parameters": [ - { - "id": "parameter-61a43a86", - "name": "k", - "type": "real", - "value": "1" - } - ] + "equations": "y = k*u;" } } }, { - "id": "478e7ff8-baa1-467f-8707-e375b99395f6", - "name": "differentiate0", + "id": "0b29c706-eb1b-4839-8ad2-bbed64efc01e", + "name": "const_and_time", "position": { - "x": 0.0, - "y": -160.0 + "x": -160.0, + "y": -32.0 }, "rotation": 0.0, "interface": { - "inputs": [ - { - "id": "port-1cbabc8f", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], + "inputs": [], "outputs": [ { - "id": "port-4eeea4e7", - "name": "out", + "id": "port-ddc97277", + "name": "y", "position": { "x": 0.0, "y": 0.0 @@ -246,6 +303,14 @@ } ] }, + "parameters": [ + { + "id": "parameter-022177fc", + "name": "v", + "type": "real", + "value": "4.8" + } + ], "icon": { "shape": "rectangle", "fill": "#f4f4f4", @@ -269,123 +334,37 @@ "y": 32.0 }, { - "color": "#00007f", - "fill": "#ffffff", - "fontSize": 18.0, + "fill": "none", "height": 48.0, "lineStyle": "solid", "lineWidth": 1.5, "stroke": "#00007f", - "text": "d/dt", - "type": "text", - "width": 48.0, + "type": "line", + "width": 0.0, "x": 40.0, "y": 40.0 - } - ] - }, - "properties": { - "showName": false - }, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": "initial out = initial;\nout = der(in);", - "parameters": [ - { - "id": "parameter-331e38be", - "name": "initial", - "type": "real", - "value": "0" - } - ] - } - } - }, - { - "id": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3", - "name": "g_P", - "position": { - "x": -64.0, - "y": -288.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "port-df34ce84", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], - "outputs": [ - { - "id": "port-fe9e6486", - "name": "out", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 88.0, - "y": 40.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text", - "size": { - "width": 128.0, - "height": 128.0 - }, - "elements": [ - { - "cornerRadius": 5.0, - "fill": "#f4f4f4", - "height": 64.0, - "lineStyle": "solid", - "lineWidth": 1.5, - "stroke": "#303030", - "type": "rectangle", - "width": 64.0, - "x": 32.0, - "y": 32.0 }, { - "color": "#00007f", - "fill": "#ffffff", - "fontSize": 24.0, - "height": 48.0, + "fill": "none", + "height": 0.0, "lineStyle": "solid", "lineWidth": 1.5, "stroke": "#00007f", - "text": "K", - "type": "text", + "type": "line", "width": 48.0, "x": 40.0, - "y": 40.0 + "y": 88.0 + }, + { + "fill": "none", + "height": 0.0, + "lineStyle": "solid", + "lineWidth": 1.0, + "stroke": "#ffaa00", + "type": "line", + "width": 48.0, + "x": 40.0, + "y": 64.0 } ] }, @@ -398,23 +377,15 @@ "implementation": { "kind": "text", "source": { - "equations": "out = k*in;", - "parameters": [ - { - "id": "parameter-61a43a86", - "name": "k", - "type": "real", - "value": "1" - } - ] + "equations": "y = v+time;" } } }, { - "id": "1995074c-4580-4ca3-8691-6923aedc5833", - "name": "g_I", + "id": "43f739a0-e50f-44ae-bd95-0e0c1a10d1cf", + "name": "gain1", "position": { - "x": -128.0, + "x": -32.0, "y": -32.0 }, "rotation": 0.0, @@ -422,7 +393,7 @@ "inputs": [ { "id": "port-df34ce84", - "name": "in", + "name": "u", "position": { "x": 0.0, "y": 0.0 @@ -440,7 +411,7 @@ "outputs": [ { "id": "port-fe9e6486", - "name": "out", + "name": "y", "position": { "x": 0.0, "y": 0.0 @@ -456,6 +427,14 @@ } ] }, + "parameters": [ + { + "id": "parameter-61a43a86", + "name": "k", + "type": "real", + "value": "-5" + } + ], "icon": { "shape": "rectangle", "fill": "#f4f4f4", @@ -503,31 +482,23 @@ "implementation": { "kind": "text", "source": { - "equations": "out = k*in;", - "parameters": [ - { - "id": "parameter-61a43a86", - "name": "k", - "type": "real", - "value": "1" - } - ] + "equations": "y = k*u;" } } }, { - "id": "c7440f4d-f206-4c27-9fd2-5722950f207f", + "id": "59bb51be-8185-4b0b-a894-e38db61aff18", "name": "add0", "position": { - "x": 128.0, - "y": -160.0 + "x": 96.0, + "y": -96.0 }, "rotation": 0.0, "interface": { "inputs": [ { "id": "port-c995845c", - "name": "in", + "name": "u", "position": { "x": 0.0, "y": 0.0 @@ -545,7 +516,7 @@ "outputs": [ { "id": "port-a2f0dea6", - "name": "out", + "name": "y", "position": { "x": 0.0, "y": 0.0 @@ -561,6 +532,7 @@ } ] }, + "parameters": [], "icon": { "shape": "rectangle", "fill": "#f4f4f4", @@ -607,251 +579,20 @@ "implementation": { "kind": "text", "source": { - "equations": "out = sum(in[i] for i in 1:in.N);", - "parameters": [] - } - } - }, - { - "id": "48af4110-839a-45f7-a105-7e7dbc51676b", - "name": "integrate0", - "position": { - "x": 0.0, - "y": -32.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "port-12903207", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], - "outputs": [ - { - "id": "port-b3370b1a", - "name": "out", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 88.0, - "y": 40.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text", - "size": { - "width": 128.0, - "height": 128.0 - }, - "elements": [ - { - "cornerRadius": 5.0, - "fill": "#f4f4f4", - "height": 64.0, - "lineStyle": "solid", - "lineWidth": 1.5, - "stroke": "#303030", - "type": "rectangle", - "width": 64.0, - "x": 32.0, - "y": 32.0 - }, - { - "color": "#00007f", - "fill": "#ffffff", - "fontSize": 18.0, - "height": 48.0, - "lineStyle": "solid", - "lineWidth": 1.5, - "stroke": "#00007f", - "text": "dt", - "type": "text", - "width": 28.0, - "x": 64.0, - "y": 40.0 - }, - { - "color": "#00007f", - "fill": "none", - "fontSize": 32.0, - "height": 54.0, - "lineStyle": "none", - "lineWidth": 1.5, - "stroke": "#00007f", - "text": "\u222b", - "type": "text", - "width": 28.0, - "x": 40.0, - "y": 32.0 - } - ] - }, - "properties": { - "showName": false - }, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": "initial out = initial;\nder(out) = in;", - "parameters": [ - { - "id": "parameter-6bdc1c76", - "name": "initial", - "type": "real", - "value": "0" - } - ] + "equations": "y = sum(u[i] for i in 1:$u_N$ );" } } } ], "connections": [ { - "id": "f89a8edb-b662-4a55-aa00-6d681b0bf6ae", + "id": "df23ff13-2a30-4c85-be66-56f66114d58e", "source": { - "block": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3", - "port": "port-fe9e6486" + "block": "2e6a192c-5aeb-45d6-80ee-d64a72392b76", + "port": "port-ddc97277" }, "target": { - "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", - "port": "port-c995845c" - }, - "name": "", - "properties": { - "waypoints": [ - { - "x": 192.0, - "y": -224.0 - } - ] - } - }, - { - "id": "c1a2f8e2-b98f-45ee-9ef6-fad8cd42b71b", - "source": { - "block": "478e7ff8-baa1-467f-8707-e375b99395f6", - "port": "port-4eeea4e7" - }, - "target": { - "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", - "port": "port-c995845c" - }, - "name": "", - "properties": { - "waypoints": [] - } - }, - { - "id": "e889f7bd-c684-4e59-8e9d-1693bd2ba87f", - "source": { - "block": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8", - "port": "port-fe9e6486" - }, - "target": { - "block": "478e7ff8-baa1-467f-8707-e375b99395f6", - "port": "port-1cbabc8f" - }, - "name": "", - "properties": { - "waypoints": [] - } - }, - { - "id": "1cebb01c-7da4-48c7-b7ea-5585f64cd524", - "source": { - "block": "1995074c-4580-4ca3-8691-6923aedc5833", - "port": "port-fe9e6486" - }, - "target": { - "block": "48af4110-839a-45f7-a105-7e7dbc51676b", - "port": "port-12903207" - }, - "name": "", - "properties": { - "waypoints": [] - } - }, - { - "id": "6d16540e-d902-4ebf-a6a1-dcdda0cc8458", - "source": { - "block": "48af4110-839a-45f7-a105-7e7dbc51676b", - "port": "port-b3370b1a" - }, - "target": { - "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", - "port": "port-c995845c" - }, - "name": "", - "properties": { - "waypoints": [ - { - "x": 192.0, - "y": 32.0 - } - ] - } - }, - { - "id": "206f1501-4509-4ba7-a28e-156e35de51ea", - "source": { - "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", - "port": "port-a2f0dea6" - }, - "target": { - "interface": "port-c75d0f16" - }, - "name": "", - "properties": { - "waypoints": [] - } - }, - { - "id": "5d234bec-8754-46e8-b9e8-1c802bb9f9ba", - "source": { - "interface": "port-1342716d" - }, - "target": { - "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" - }, - "name": "", - "properties": { - "waypoints": [] - } - }, - { - "id": "33ba6f27-6f3a-4290-baac-b3cddcaa6b50", - "source": { - "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" - }, - "target": { - "block": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8", + "block": "f4a78255-8b73-4bb3-ae8e-24b566c72bc0", "port": "port-df34ce84" }, "name": "", @@ -860,58 +601,54 @@ } }, { - "id": "ee4a3409-fc46-485c-b475-b456dddb93b5", + "id": "00f7d291-641b-4200-afa3-f1ec941faed7", "source": { - "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" + "block": "0b29c706-eb1b-4839-8ad2-bbed64efc01e", + "port": "port-ddc97277" }, "target": { - "block": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3", + "block": "43f739a0-e50f-44ae-bd95-0e0c1a10d1cf", "port": "port-df34ce84" }, "name": "", "properties": { - "waypoints": [ - { - "x": -192.0, - "y": -224.0 - } - ] + "waypoints": [] } }, { - "id": "8d1a5f3c-b706-4671-873a-2121f55f748b", + "id": "7a776aab-deb5-4e50-838e-b6b4635f349c", "source": { - "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" + "block": "f4a78255-8b73-4bb3-ae8e-24b566c72bc0", + "port": "port-fe9e6486" }, "target": { - "block": "1995074c-4580-4ca3-8691-6923aedc5833", - "port": "port-df34ce84" + "block": "59bb51be-8185-4b0b-a894-e38db61aff18", + "port": "port-c995845c" }, "name": "", "properties": { - "waypoints": [ - { - "x": -192.0, - "y": 32.0 - } - ] + "waypoints": [] + } + }, + { + "id": "5b3d71c3-282a-4c2d-85f8-b92bba5bf2df", + "source": { + "block": "43f739a0-e50f-44ae-bd95-0e0c1a10d1cf", + "port": "port-fe9e6486" + }, + "target": { + "block": "59bb51be-8185-4b0b-a894-e38db61aff18", + "port": "port-c995845c" + }, + "name": "", + "properties": { + "waypoints": [] } } ], "annotations": [], - "junctions": [ - { - "id": "7a552e5e-b15d-46ca-a891-37e5129f88d1", - "position": { - "x": -192.0, - "y": -96.0 - }, - "type": "signal" - } - ], - "simulation": { - "enabled": true - } + "junctions": [], + "simulation": {} } } } diff --git a/Test.mo b/Test.mo new file mode 100644 index 0000000..b46ea7e --- /dev/null +++ b/Test.mo @@ -0,0 +1,44 @@ +model model_6060fdd6_e816_4da9_8bb6_fde1a66d3e92 + model model_2e6a192c_5aeb_45d6_80ee_d64a72392b76 + output Real y; + parameter Real v = 1; + equation + y = v; + end model_2e6a192c_5aeb_45d6_80ee_d64a72392b76; + model f4a78255_8b73_4bb3_ae8e_24b566c72bc0 + input Real u; + output Real y; + parameter Real k = 2.5; + equation + y = k*u; + end f4a78255_8b73_4bb3_ae8e_24b566c72bc0; + model model_0b29c706_eb1b_4839_8ad2_bbed64efc01e + output Real y; + parameter Real v = 4.8; + equation + y = v+time; + end model_0b29c706_eb1b_4839_8ad2_bbed64efc01e; + model model_43f739a0_e50f_44ae_bd95_0e0c1a10d1cf + input Real u; + output Real y; + parameter Real k = -5; + equation + y = k*u; + end model_43f739a0_e50f_44ae_bd95_0e0c1a10d1cf; + model model_59bb51be_8185_4b0b_a894_e38db61aff18 + input Real u[2]; + output Real y; + equation + y = sum(u[i] for i in 1:2 ); + end model_59bb51be_8185_4b0b_a894_e38db61aff18; + model_2e6a192c_5aeb_45d6_80ee_d64a72392b76 Constant0; + f4a78255_8b73_4bb3_ae8e_24b566c72bc0 gain0; + model_0b29c706_eb1b_4839_8ad2_bbed64efc01e const_and_time; + model_43f739a0_e50f_44ae_bd95_0e0c1a10d1cf gain1; + model_59bb51be_8185_4b0b_a894_e38db61aff18 add0; +equation + gain0.u = Constant0.y; + gain1.u = const_and_time.y; + add0.u[1] = gain0.y; + add0.u[2] = gain1.y; +end model_6060fdd6_e816_4da9_8bb6_fde1a66d3e92;