diff --git a/docs/gui_doc.md b/docs/gui_doc.md index 20d3ddc..00dd110 100644 --- a/docs/gui_doc.md +++ b/docs/gui_doc.md @@ -237,17 +237,17 @@ Sometimes, the value or visibility of one parameter depends on the value of anot Example (based on pid): ``` python -def is_parameter_active(self, param_name: str, instance_values: Dict[str, Any]) -> bool: +def is_parameter_active(self, param_name: str, instance_params: Dict[str, Any]) -> bool: if param_name == "Kp": - return instance_values["controller"] in ["P", "PI", "PD", "PID"] + return instance_params["controller"] in ["P", "PI", "PD", "PID"] elif param_name == "Ki": - return instance_values["controller"] in ["I", "PI", "PID"] + return instance_params["controller"] in ["I", "PI", "PID"] elif param_name == "Kd": - return instance_values["controller"] in ["PD", "PID"] - return super().is_parameter_active(param_name, instance_values) + return instance_params["controller"] in ["PD", "PID"] + return super().is_parameter_active(param_name, instance_params) ``` Explanation: -- `instance_values` contains the current values of all parameters for this block instance. +- `instance_params` contains the current values of all parameters for this block instance. - The `controller` parameter determines which other parameters are active: - if the controller type includes `P`, then `Kp` is active. - if it includes `I`, then `Ki` is active. diff --git a/docs/uml/pySimBlocks-GUI.uxf b/docs/uml/pySimBlocks-GUI.uxf index 134e6d1..f4806da 100644 --- a/docs/uml/pySimBlocks-GUI.uxf +++ b/docs/uml/pySimBlocks-GUI.uxf @@ -168,7 +168,7 @@ description: str doc_path: Path | None -- get_param(param_name: str) -> ParameterMeta | None -is_parameter_active(param_name: str, instance_values: Dict[str, Any]) -> bool +is_parameter_active(param_name: str, instance_params: Dict[str, Any]) -> bool resolve_port_group(PortMeta, direction: str, BlockInstance) -> list[PortInstance] build_ports(BlockInstance) -> list[PortInstance) diff --git a/pySimBlocks/gui/addons/sofa/sofa_service.py b/pySimBlocks/gui/addons/sofa/sofa_service.py index 1488276..5e3c087 100644 --- a/pySimBlocks/gui/addons/sofa/sofa_service.py +++ b/pySimBlocks/gui/addons/sofa/sofa_service.py @@ -24,7 +24,7 @@ from PySide6.QtCore import QProcess, QProcessEnvironment -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController from pySimBlocks.gui.services.yaml_tools import save_yaml from pySimBlocks.project.generate_sofa_controller import generate_sofa_controller diff --git a/pySimBlocks/gui/blocks/block_dialog_session.py b/pySimBlocks/gui/blocks/block_dialog_session.py new file mode 100644 index 0000000..f321cc9 --- /dev/null +++ b/pySimBlocks/gui/blocks/block_dialog_session.py @@ -0,0 +1,47 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# ****************************************************************************** +# Authors: see Authors.txt +# ****************************************************************************** + +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import QLineEdit + +from pySimBlocks.gui.models.block_instance import BlockInstance + +if TYPE_CHECKING: + from pySimBlocks.gui.blocks.block_meta import BlockMeta + + +class BlockDialogSession: + def __init__( + self, + meta: "BlockMeta", + instance: BlockInstance, + project_dir: Path | None = None, + ): + self.meta = meta + self.instance = instance + self.project_dir = project_dir + + # --- STATE UI (par dialog) --- + self.local_params = dict(instance.parameters) + self.param_widgets = {} + self.param_labels = {} + self.name_edit: QLineEdit | None = None diff --git a/pySimBlocks/gui/blocks/block_meta.py b/pySimBlocks/gui/blocks/block_meta.py index f8c6973..38b48bb 100644 --- a/pySimBlocks/gui/blocks/block_meta.py +++ b/pySimBlocks/gui/blocks/block_meta.py @@ -18,13 +18,32 @@ # Authors: see Authors.txt # ****************************************************************************** +import ast +import os from abc import ABC from pathlib import Path -from typing import Any, Dict, List, Literal +from typing import Any, Dict, Literal, Sequence +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QFileDialog, + QFormLayout, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from pySimBlocks.gui.blocks.block_dialog_session import BlockDialogSession from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class BlockMeta(ABC): @@ -80,25 +99,42 @@ def __init__(self): description: str # ----------- Optional declarations ----------- - doc_path: Path | None = None - parameters: List[ParameterMeta] = [] - inputs: List[PortMeta] = [] - outputs: List[PortMeta] = [] + parameters: Sequence[ParameterMeta] = () + inputs: Sequence[PortMeta] = () + outputs: Sequence[PortMeta] = () - def get_param(self, param_name: str) -> ParameterMeta | None: - for param in self.parameters: - if param.name == param_name: - return param - return None + # -------------------------------------------------------------------------- + # Dialog session management + # -------------------------------------------------------------------------- + def create_dialog_session( + self, + instance: BlockInstance, + project_dir: Path | None = None, + ) -> BlockDialogSession: + return BlockDialogSession(self, instance, project_dir) - def is_parameter_active(self, param_name: str, instance_values: Dict[str, Any]) -> bool: + # -------------------------------------------------------------------------- + # Parameter resolution + # -------------------------------------------------------------------------- + def is_parameter_active(self, + param_name: str, + instance_params: Dict[str, Any]) -> bool: """ Default: all parameters are always active. Children override if needed. """ return True - + + # ------------------------------------------------------------ + def gather_params(self, session: BlockDialogSession) -> dict[str, Any]: + # Keep full local state, including inactive params, so values are cached + # across visibility toggles and dialog reopen. + return session.local_params.copy() + + # -------------------------------------------------------------------------- + # Port resolution + # -------------------------------------------------------------------------- def resolve_port_group(self, port_meta: PortMeta, direction: Literal['input', 'output'], @@ -110,6 +146,7 @@ def resolve_port_group(self, """ return [PortInstance(port_meta.name, port_meta.display_as, direction, instance)] + # ------------------------------------------------------------ def build_ports(self, instance: "BlockInstance") -> list["PortInstance"]: ports = [] @@ -120,3 +157,248 @@ def build_ports(self, instance: "BlockInstance") -> list["PortInstance"]: ports.extend(self.resolve_port_group(pmeta, "output", instance)) return ports + + + # -------------------------------------------------------------------------- + # QT dialog display + # -------------------------------------------------------------------------- + def build_description(self, form: QFormLayout): + """ Default description display. Children can override if needed. """ + title = QLabel(f"{self.name}") + title.setAlignment(Qt.AlignmentFlag.AlignLeft) + form.addRow(title) + + frame = QFrame() + frame.setFrameShape(QFrame.StyledPanel) + frame.setFrameShadow(QFrame.Raised) + frame.setLineWidth(1) + + frame_layout = QVBoxLayout(frame) + frame_layout.setContentsMargins(8, 6, 8, 6) + + desc = QTextBrowser() + desc.setMarkdown(self.description) + desc.setReadOnly(True) + desc.setFrameShape(QFrame.NoFrame) + desc.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + desc.document().setTextWidth(400) + desc.setFixedHeight(int(desc.document().size().height()) + 6) + + frame_layout.addWidget(desc) + form.addRow(frame) + + + # ------------------------------------------------------ + def build_pre_param(self, + session: BlockDialogSession, + form: QFormLayout, + readonly: bool = False): + """ Default: no pre-parameter widgets. Children override if needed. """ + pass + + # ------------------------------------------------------------ + def build_param(self, + session: BlockDialogSession, + form: QFormLayout, + readonly: bool = False): + """ Default: no parameter widgets. Children override if needed. """ + + + # --- Block name --- + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + form.addRow(QLabel("Block name:"), name_edit) + if readonly: + name_edit.setReadOnly(True) + session.name_edit = name_edit + + # --- Parameters --- + for param_meta in self.parameters: + param_name = param_meta.name + + label, widget = self._create_param_row(session, param_meta, readonly) + if widget is None: + continue + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + + session.param_widgets[param_name] = widget + session.param_labels[param_name] = label + + + # ------------------------------------------------------------ + def build_post_param(self, + session: BlockDialogSession, + form: QFormLayout, + readonly: bool = False): + """ Default: no post-parameter widgets. Children override if needed. """ + pass + + # ------------------------------------------------------------ + def build_file_param_row( + self, + session: BlockDialogSession, + form: QFormLayout, + pmeta: ParameterMeta, + readonly: bool = False, + file_filter: str = "Python files (*.py);;All files (*)", + ) -> None: + edit = self._create_edit_widget(session, pmeta, readonly) + if readonly: + self._set_readonly_style(edit) + + browse_btn = QPushButton("...") + browse_btn.setToolTip("Select file from disk") + browse_btn.setEnabled(not readonly) + browse_btn.clicked.connect( + lambda: self._browse_and_set_relative_file(edit, session.project_dir, file_filter) + ) + + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addWidget(edit) + row_layout.addWidget(browse_btn) + + label = QLabel(f"{pmeta.name}:") + if pmeta.description: + label.setToolTip(pmeta.description) + + form.addRow(label, row_widget) + session.param_widgets[pmeta.name] = row_widget + session.param_labels[pmeta.name] = label + + # ------------------------------------------------------------ + def refresh_form(self, session: BlockDialogSession): + """ + Refresh the parameter widgets visibility based on + BlockMeta.is_parameter_active and current local_params. + """ + + for param_name, widget in session.param_widgets.items(): + label = session.param_labels[param_name] + + active = self.is_parameter_active(param_name, session.local_params) + + widget.setVisible(active) + label.setVisible(active) + + + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + def _create_param_row(self, + session: BlockDialogSession, + pmeta: ParameterMeta, + readonly: bool = False + ) -> tuple[QLabel, QWidget]: + + # ENUM + if pmeta.type == "enum": + widget = self._create_enum_widget(session, pmeta, readonly) + + # Default: text edit + widget = self._create_edit_widget(session, pmeta, readonly) + + label = QLabel(f"{pmeta.name}:") + if pmeta.description: + label.setToolTip(pmeta.description) + + return label, widget + + + # ------------------------------------------------------------ + def _create_edit_widget(self, + session: BlockDialogSession, + pmeta: ParameterMeta, + readonly: bool = False) -> QLineEdit: + edit = QLineEdit() + value = session.local_params.get(pmeta.name) + if value is not None: + edit.setText(str(value)) + elif pmeta.default is not None: + edit.setText(str(pmeta.default)) + edit.textChanged.connect( + lambda val: self._on_param_changed(val, pmeta.name, session, readonly) + ) + return edit + + # ------------------------------------------------------------ + def _create_enum_widget(self, + session: BlockDialogSession, + pmeta: ParameterMeta, + readonly: bool = False) -> QComboBox: + combo = QComboBox() + for v in pmeta.enum: + combo.addItem(str(v), userData=v) + value = session.local_params.get(pmeta.name) + if value is not None: + combo.setCurrentText(str(value)) + combo.currentTextChanged.connect( + lambda val: self._on_param_changed(val, pmeta.name, session, readonly) + ) + return combo + + # ------------------------------------------------------------ + def _browse_and_set_relative_file( + self, + edit: QLineEdit, + project_dir: Path | None, + file_filter: str, + ) -> None: + if project_dir is None: + return + + base_dir = project_dir.expanduser() + start_dir = base_dir if base_dir.is_dir() else Path.cwd() + + selected_file, _ = QFileDialog.getOpenFileName( + edit, + "Select file", + str(start_dir), + file_filter, + ) + if not selected_file: + return + + selected_path = Path(selected_file).resolve() + base_resolved = base_dir.resolve() + try: + relative_path = selected_path.relative_to(base_resolved) + except ValueError: + relative_path = Path(os.path.relpath(str(selected_path), str(base_resolved))) + + edit.setText(str(relative_path)) + + # ------------------------------------------------------------ + def _on_param_changed( self, val: str, name: str, session: BlockDialogSession, readonly: bool,): + if readonly: + return + + if name == "name": + session.instance.name = val + else: + text = str(val).strip() + try: + session.local_params[name] = ast.literal_eval(text) + except (ValueError, SyntaxError): + session.local_params[name] = text + self.refresh_form(session) + + # ------------------------------------------------------------ + def _set_readonly_style(self, widget: QWidget): + if isinstance(widget, QLineEdit): + widget.setReadOnly(True) + widget.setStyleSheet(""" + QLineEdit { + background-color: #2b2b2b; + color: #888888; + border: 1px solid #444444; + } + """) + elif isinstance(widget, QComboBox): + widget.setEnabled(False) diff --git a/pySimBlocks/gui/blocks/controllers/pid.py b/pySimBlocks/gui/blocks/controllers/pid.py index 017626f..30750f2 100644 --- a/pySimBlocks/gui/blocks/controllers/pid.py +++ b/pySimBlocks/gui/blocks/controllers/pid.py @@ -104,13 +104,13 @@ def __init__(self): ) ] - def is_parameter_active(self, param_name: str, instance_values: Dict[str, Any]) -> bool: + def is_parameter_active(self, param_name: str, instance_params: Dict[str, Any]) -> bool: if param_name == "Kp": - return instance_values["controller"] in ["P", "PI", "PD", "PID"] + return instance_params["controller"] in ["P", "PI", "PD", "PID"] elif param_name == "Ki": - return instance_values["controller"] in ["I", "PI", "PID"] + return instance_params["controller"] in ["I", "PI", "PID"] elif param_name == "Kd": - return instance_values["controller"] in ["PD", "PID"] + return instance_params["controller"] in ["PD", "PID"] - return super().is_parameter_active(param_name, instance_values) + return super().is_parameter_active(param_name, instance_params) diff --git a/pySimBlocks/gui/blocks/operators/algebraic_function.py b/pySimBlocks/gui/blocks/operators/algebraic_function.py index 69742a1..62faea8 100644 --- a/pySimBlocks/gui/blocks/operators/algebraic_function.py +++ b/pySimBlocks/gui/blocks/operators/algebraic_function.py @@ -18,10 +18,17 @@ # Authors: see Authors.txt # ****************************************************************************** +import os +import subprocess +import sys +from pathlib import Path from typing import Literal + +from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton + from pySimBlocks.gui.blocks.block_meta import BlockMeta, ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class AlgebraicFunctionMeta(BlockMeta): @@ -88,6 +95,10 @@ def __init__(self): ) ] + + # -------------------------------------------------------------------------- + # Port resolution + # -------------------------------------------------------------------------- def resolve_port_group( self, port_meta: PortMeta, @@ -127,3 +138,95 @@ def resolve_port_group( return ports return super().resolve_port_group(port_meta, direction, instance) + + # -------------------------------------------------------------------------- + # Dialog methods + # -------------------------------------------------------------------------- + def build_param( + self, + session, + form: QFormLayout, + readonly: bool = False, + ): + # --- Block name --- + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + form.addRow(QLabel("Block name:"), name_edit) + if readonly: + name_edit.setReadOnly(True) + session.name_edit = name_edit + + # --- Parameters --- + for pmeta in self.parameters: + if pmeta.name == "file_path": + self.build_file_param_row( + session, + form, + pmeta, + readonly=readonly, + file_filter="Python files (*.py);;All files (*)", + ) + continue + + label, widget = self._create_param_row(session, pmeta, readonly) + if widget is None: + continue + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + session.param_widgets[pmeta.name] = widget + session.param_labels[pmeta.name] = label + + # ------------------------------------------------------ + def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + open_btn = QPushButton("Open file") + open_btn.clicked.connect(lambda: self._open_file_from_session(session)) + form.addRow(QLabel(""), open_btn) + session.open_file_btn = open_btn + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def refresh_form(self, session): + super().refresh_form(session) + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def _resolve_file_path(self, session) -> Path | None: + raw = session.local_params.get("file_path") + if not raw: + return None + + path = Path(str(raw)).expanduser() + if not path.is_absolute() and session.project_dir is not None: + path = (session.project_dir / path).resolve() + return path + + # ------------------------------------------------------ + def _refresh_open_button_state(self, session) -> None: + btn = getattr(session, "open_file_btn", None) + if btn is None: + return + + target = self._resolve_file_path(session) + exists = target is not None and target.is_file() + btn.setEnabled(exists) + if exists: + btn.setToolTip(str(target)) + else: + btn.setToolTip("Set a valid existing file_path to open the file.") + + # ------------------------------------------------------ + def _open_file_from_session(self, session) -> None: + target = self._resolve_file_path(session) + if target is None or not target.is_file(): + return + + if sys.platform.startswith("darwin"): + subprocess.Popen(["open", str(target)]) + elif os.name == "nt": + os.startfile(str(target)) + else: + subprocess.Popen(["xdg-open", str(target)]) diff --git a/pySimBlocks/gui/blocks/operators/demux.py b/pySimBlocks/gui/blocks/operators/demux.py index 42c652b..9690f67 100644 --- a/pySimBlocks/gui/blocks/operators/demux.py +++ b/pySimBlocks/gui/blocks/operators/demux.py @@ -23,7 +23,7 @@ from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class DemuxMeta(BlockMeta): diff --git a/pySimBlocks/gui/blocks/operators/mux.py b/pySimBlocks/gui/blocks/operators/mux.py index 948583a..8dc7dc9 100644 --- a/pySimBlocks/gui/blocks/operators/mux.py +++ b/pySimBlocks/gui/blocks/operators/mux.py @@ -22,7 +22,7 @@ from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class MuxMeta(BlockMeta): diff --git a/pySimBlocks/gui/blocks/operators/product.py b/pySimBlocks/gui/blocks/operators/product.py index 2326feb..90bff20 100644 --- a/pySimBlocks/gui/blocks/operators/product.py +++ b/pySimBlocks/gui/blocks/operators/product.py @@ -23,7 +23,7 @@ from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class ProductMeta(BlockMeta): diff --git a/pySimBlocks/gui/blocks/operators/sum.py b/pySimBlocks/gui/blocks/operators/sum.py index bec848b..3095c85 100644 --- a/pySimBlocks/gui/blocks/operators/sum.py +++ b/pySimBlocks/gui/blocks/operators/sum.py @@ -22,7 +22,7 @@ from typing import Literal from pySimBlocks.gui.blocks.block_meta import BlockMeta, ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class SumMeta(BlockMeta): diff --git a/pySimBlocks/gui/blocks/sources/file_source.py b/pySimBlocks/gui/blocks/sources/file_source.py index 35ca298..5e3a5e8 100644 --- a/pySimBlocks/gui/blocks/sources/file_source.py +++ b/pySimBlocks/gui/blocks/sources/file_source.py @@ -18,8 +18,14 @@ # Authors: see Authors.txt # ****************************************************************************** +import os +import subprocess +import sys +from pathlib import Path from typing import Any, Dict +from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton + from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta @@ -84,8 +90,11 @@ def __init__(self): ) ] - def is_parameter_active(self, param_name: str, instance_values: Dict[str, Any]) -> bool: - file_path = str(instance_values.get("file_path", "") or "") + # ------------------------------------------------------ + def is_parameter_active(self, + param_name: str, + instance_params: Dict[str, Any]) -> bool: + file_path = str(instance_params.get("file_path", "") or "") ext = file_path.rsplit(".", 1)[-1].lower() if "." in file_path else "" if param_name == "key": @@ -94,4 +103,94 @@ def is_parameter_active(self, param_name: str, instance_values: Dict[str, Any]) if param_name == "use_time": return ext in {"npz", "csv"} - return super().is_parameter_active(param_name, instance_values) + return super().is_parameter_active(param_name, instance_params) + + # -------------------------------------------------------------------------- + # Dialog Methods + # -------------------------------------------------------------------------- + def build_param( + self, + session, + form: QFormLayout, + readonly: bool = False, + ): + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + form.addRow(QLabel("Block name:"), name_edit) + if readonly: + name_edit.setReadOnly(True) + session.name_edit = name_edit + + for pmeta in self.parameters: + if pmeta.name == "file_path": + self.build_file_param_row( + session, + form, + pmeta, + readonly=readonly, + file_filter="Data files (*.npz *.npy *.csv);;All files (*)", + ) + continue + + label, widget = self._create_param_row(session, pmeta, readonly) + if widget is None: + continue + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + session.param_widgets[pmeta.name] = widget + session.param_labels[pmeta.name] = label + + # ------------------------------------------------------ + def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + open_btn = QPushButton("Open file") + open_btn.clicked.connect(lambda: self._open_file_from_session(session)) + form.addRow(QLabel(""), open_btn) + session.open_file_btn = open_btn + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def refresh_form(self, session): + super().refresh_form(session) + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def _resolve_file_path(self, session) -> Path | None: + raw = session.local_params.get("file_path") + if not raw: + return None + + path = Path(str(raw)).expanduser() + if not path.is_absolute() and session.project_dir is not None: + path = (session.project_dir / path).resolve() + return path + + # ------------------------------------------------------ + def _refresh_open_button_state(self, session) -> None: + btn = getattr(session, "open_file_btn", None) + if btn is None: + return + + target = self._resolve_file_path(session) + exists = target is not None and target.is_file() + btn.setEnabled(exists) + if exists: + btn.setToolTip(str(target)) + else: + btn.setToolTip("Set a valid existing file_path to open the file.") + + # ------------------------------------------------------ + def _open_file_from_session(self, session) -> None: + target = self._resolve_file_path(session) + if target is None or not target.is_file(): + return + + if sys.platform.startswith("darwin"): + subprocess.Popen(["open", str(target)]) + elif os.name == "nt": + os.startfile(str(target)) + else: + subprocess.Popen(["xdg-open", str(target)]) diff --git a/pySimBlocks/gui/blocks/systems/non_linear_state_space.py b/pySimBlocks/gui/blocks/systems/non_linear_state_space.py index 3735cb8..316fb91 100644 --- a/pySimBlocks/gui/blocks/systems/non_linear_state_space.py +++ b/pySimBlocks/gui/blocks/systems/non_linear_state_space.py @@ -18,10 +18,17 @@ # Authors: see Authors.txt # ****************************************************************************** +import os +import subprocess +import sys +from pathlib import Path from typing import Literal + +from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton + from pySimBlocks.gui.blocks.block_meta import BlockMeta, ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class NonLinearStateSpaceMeta(BlockMeta): @@ -100,6 +107,9 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Port resolution + # -------------------------------------------------------------------------- def resolve_port_group( self, port_meta: PortMeta, @@ -131,3 +141,93 @@ def resolve_port_group( ) for key in output_keys ] return super().resolve_port_group(port_meta, direction, instance) + + # -------------------------------------------------------------------------- + # Dialog methods + # -------------------------------------------------------------------------- + def build_param( + self, + session, + form: QFormLayout, + readonly: bool = False, + ): + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + form.addRow(QLabel("Block name:"), name_edit) + if readonly: + name_edit.setReadOnly(True) + session.name_edit = name_edit + + for pmeta in self.parameters: + if pmeta.name == "file_path": + self.build_file_param_row( + session, + form, + pmeta, + readonly=readonly, + file_filter="Python files (*.py);;All files (*)", + ) + continue + + label, widget = self._create_param_row(session, pmeta, readonly) + if widget is None: + continue + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + session.param_widgets[pmeta.name] = widget + session.param_labels[pmeta.name] = label + + # ------------------------------------------------------ + def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + open_btn = QPushButton("Open file") + open_btn.clicked.connect(lambda: self._open_file_from_session(session)) + form.addRow(QLabel(""), open_btn) + session.open_file_btn = open_btn + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def refresh_form(self, session): + super().refresh_form(session) + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def _resolve_file_path(self, session) -> Path | None: + raw = session.local_params.get("file_path") + if not raw: + return None + + path = Path(str(raw)).expanduser() + if not path.is_absolute() and session.project_dir is not None: + path = (session.project_dir / path).resolve() + return path + + # ------------------------------------------------------ + def _refresh_open_button_state(self, session) -> None: + btn = getattr(session, "open_file_btn", None) + if btn is None: + return + + target = self._resolve_file_path(session) + exists = target is not None and target.is_file() + btn.setEnabled(exists) + if exists: + btn.setToolTip(str(target)) + else: + btn.setToolTip("Set a valid existing file_path to open the file.") + + # ------------------------------------------------------ + def _open_file_from_session(self, session) -> None: + target = self._resolve_file_path(session) + if target is None or not target.is_file(): + return + + if sys.platform.startswith("darwin"): + subprocess.Popen(["open", str(target)]) + elif os.name == "nt": + os.startfile(str(target)) + else: + subprocess.Popen(["xdg-open", str(target)]) diff --git a/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py b/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py index abbc83b..7d78730 100644 --- a/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +++ b/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py @@ -18,11 +18,18 @@ # Authors: see Authors.txt # ****************************************************************************** +import os +import subprocess +import sys +from pathlib import Path from typing import Literal + +from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton + from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class SofaExchangeIOMeta(BlockMeta): @@ -83,6 +90,9 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Port Resolution + # -------------------------------------------------------------------------- def resolve_port_group( self, port_meta: PortMeta, @@ -119,3 +129,93 @@ def resolve_port_group( ] return super().resolve_port_group(port_meta, direction, instance) + + # -------------------------------------------------------------------------- + # Dialog Methods + # -------------------------------------------------------------------------- + def build_param( + self, + session, + form: QFormLayout, + readonly: bool = False, + ): + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + form.addRow(QLabel("Block name:"), name_edit) + if readonly: + name_edit.setReadOnly(True) + session.name_edit = name_edit + + for pmeta in self.parameters: + if pmeta.name == "scene_file": + self.build_file_param_row( + session, + form, + pmeta, + readonly=readonly, + file_filter="SOFA scene files (*.py);;All files (*)", + ) + continue + + label, widget = self._create_param_row(session, pmeta, readonly) + if widget is None: + continue + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + session.param_widgets[pmeta.name] = widget + session.param_labels[pmeta.name] = label + + # ------------------------------------------------------ + def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + open_btn = QPushButton("Open file") + open_btn.clicked.connect(lambda: self._open_file_from_session(session)) + form.addRow(QLabel(""), open_btn) + session.open_file_btn = open_btn + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def refresh_form(self, session): + super().refresh_form(session) + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def _resolve_file_path(self, session) -> Path | None: + raw = session.local_params.get("scene_file") + if not raw: + return None + + path = Path(str(raw)).expanduser() + if not path.is_absolute() and session.project_dir is not None: + path = (session.project_dir / path).resolve() + return path + + # ------------------------------------------------------ + def _refresh_open_button_state(self, session) -> None: + btn = getattr(session, "open_file_btn", None) + if btn is None: + return + + target = self._resolve_file_path(session) + exists = target is not None and target.is_file() + btn.setEnabled(exists) + if exists: + btn.setToolTip(str(target)) + else: + btn.setToolTip("Set a valid existing scene_file to open the file.") + + # ------------------------------------------------------ + def _open_file_from_session(self, session) -> None: + target = self._resolve_file_path(session) + if target is None or not target.is_file(): + return + + if sys.platform.startswith("darwin"): + subprocess.Popen(["open", str(target)]) + elif os.name == "nt": + os.startfile(str(target)) + else: + subprocess.Popen(["xdg-open", str(target)]) diff --git a/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py index e205cc8..ede6b49 100644 --- a/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +++ b/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py @@ -18,11 +18,18 @@ # Authors: see Authors.txt # ****************************************************************************** +import os +import subprocess +import sys +from pathlib import Path from typing import Literal + +from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton + from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta from pySimBlocks.gui.blocks.port_meta import PortMeta -from pySimBlocks.gui.model import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance class SofaPlantMeta(BlockMeta): @@ -82,6 +89,9 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Port Resolution + # -------------------------------------------------------------------------- def resolve_port_group( self, port_meta: PortMeta, @@ -118,3 +128,93 @@ def resolve_port_group( ] return super().resolve_port_group(port_meta, direction, instance) + + # -------------------------------------------------------------------------- + # Dialog Methods + # -------------------------------------------------------------------------- + def build_param( + self, + session, + form: QFormLayout, + readonly: bool = False, + ): + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + form.addRow(QLabel("Block name:"), name_edit) + if readonly: + name_edit.setReadOnly(True) + session.name_edit = name_edit + + for pmeta in self.parameters: + if pmeta.name == "scene_file": + self.build_file_param_row( + session, + form, + pmeta, + readonly=readonly, + file_filter="SOFA scene files (*.py);;All files (*)", + ) + continue + + label, widget = self._create_param_row(session, pmeta, readonly) + if widget is None: + continue + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + session.param_widgets[pmeta.name] = widget + session.param_labels[pmeta.name] = label + + # ------------------------------------------------------ + def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + open_btn = QPushButton("Open file") + open_btn.clicked.connect(lambda: self._open_file_from_session(session)) + form.addRow(QLabel(""), open_btn) + session.open_file_btn = open_btn + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def refresh_form(self, session): + super().refresh_form(session) + self._refresh_open_button_state(session) + + # ------------------------------------------------------ + def _resolve_file_path(self, session) -> Path | None: + raw = session.local_params.get("scene_file") + if not raw: + return None + + path = Path(str(raw)).expanduser() + if not path.is_absolute() and session.project_dir is not None: + path = (session.project_dir / path).resolve() + return path + + # ------------------------------------------------------ + def _refresh_open_button_state(self, session) -> None: + btn = getattr(session, "open_file_btn", None) + if btn is None: + return + + target = self._resolve_file_path(session) + exists = target is not None and target.is_file() + btn.setEnabled(exists) + if exists: + btn.setToolTip(str(target)) + else: + btn.setToolTip("Set a valid existing scene_file to open the file.") + + # ------------------------------------------------------ + def _open_file_from_session(self, session) -> None: + target = self._resolve_file_path(session) + if target is None or not target.is_file(): + return + + if sys.platform.startswith("darwin"): + subprocess.Popen(["open", str(target)]) + elif os.name == "nt": + os.startfile(str(target)) + else: + subprocess.Popen(["xdg-open", str(target)]) diff --git a/pySimBlocks/gui/dialogs/block_dialog.py b/pySimBlocks/gui/dialogs/block_dialog.py index 2f526c0..5c7f46f 100644 --- a/pySimBlocks/gui/dialogs/block_dialog.py +++ b/pySimBlocks/gui/dialogs/block_dialog.py @@ -18,31 +18,21 @@ # Authors: see Authors.txt # ****************************************************************************** -import ast +from typing import TYPE_CHECKING -from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QComboBox, QDialog, QFormLayout, - QFrame, QHBoxLayout, - QLabel, - QLineEdit, QMessageBox, QPushButton, - QSizePolicy, - QTextBrowser, QVBoxLayout, ) -from pySimBlocks.gui.blocks.block_meta import ParameterMeta from pySimBlocks.gui.dialogs.help_dialog import HelpDialog -from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from pySimBlocks.gui.graphics.block_item import BlockItem - from PySide6.QtWidgets import QWidget class BlockDialog(QDialog): @@ -52,7 +42,8 @@ def __init__(self, ): super().__init__() self.block = block - self.local_params: dict[str, Any] = dict(block.instance.parameters) + self.meta = block.instance.meta + self.instance = block.instance self.readonly = readonly if self.readonly: @@ -61,13 +52,30 @@ def __init__(self, self.setWindowTitle(f"Edit [{self.block.instance.name}] Parameters") self.setMinimumWidth(300) - self.param_widgets: dict[str, QWidget] = {} - self.param_labels: dict[str, QLabel] = {} - main_layout = QVBoxLayout(self) - self.build_parameters_form(main_layout) + project_dir = None + if hasattr(self.block, "view") and self.block.view is not None: + controller = getattr(self.block.view, "project_controller", None) + if controller is not None and controller.project_state is not None: + project_dir = controller.project_state.directory_path + + self.session = self.meta.create_dialog_session(self.instance, project_dir) + self.build_meta_layout(main_layout) + self.build_buttons_layout(main_layout) + + # -------------------------------------------------------------------------- + # Dialog Layout Methods + # -------------------------------------------------------------------------- + def build_meta_layout(self, layout: QVBoxLayout): + form = QFormLayout() + self.meta.build_description(form) + self.meta.build_pre_param(self.session, form, self.readonly) + self.meta.build_param(self.session, form, self.readonly) + self.meta.build_post_param(self.session, form, self.readonly) + layout.addLayout(form) + self.meta.refresh_form(self.session) - # --- Buttons row --- + def build_buttons_layout(self, layout: QVBoxLayout): buttons_layout = QHBoxLayout() buttons_layout.addStretch() @@ -85,129 +93,29 @@ def __init__(self, apply_btn.clicked.connect(self.apply) buttons_layout.addWidget(apply_btn) - main_layout.addLayout(buttons_layout) - - # ------------------------------------------------------------ - # Form - def description_part(self, form): - title = QLabel(f"{self.block.instance.meta.name}") - title.setAlignment(Qt.AlignmentFlag.AlignLeft) - form.addRow(title) - - frame = QFrame() - frame.setFrameShape(QFrame.StyledPanel) - frame.setFrameShadow(QFrame.Raised) - frame.setLineWidth(1) - - frame_layout = QVBoxLayout(frame) - frame_layout.setContentsMargins(8, 6, 8, 6) - - desc = QTextBrowser() - desc.setMarkdown(self.block.instance.meta.description) - desc.setReadOnly(True) - desc.setFrameShape(QFrame.NoFrame) - desc.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) - desc.document().setTextWidth(400) - desc.setFixedHeight(int(desc.document().size().height()) + 6) - - frame_layout.addWidget(desc) - form.addRow(frame) - - def build_parameters_form(self, layout): - meta = self.block.instance.meta - inst_params = self.block.instance.parameters - - form = QFormLayout() - self.description_part(form) - - # --- Block name --- - self.name_edit = QLineEdit(self.block.instance.name) - form.addRow(QLabel("Block name:"), self.name_edit) if self.readonly: - self.name_edit.setReadOnly(True) - - for param_meta in meta.parameters: - param_name = param_meta.name - - widget = self._create_param_widget(param_meta, inst_params) - if widget is None: - continue - if self.readonly: - if isinstance(widget, QLineEdit): - widget.setReadOnly(True) - widget.setStyleSheet(""" - QLineEdit { - background-color: #2b2b2b; - color: #888888; - border: 1px solid #444444; - } - """) - elif isinstance(widget, QComboBox): - widget.setEnabled(False) - - label = QLabel(f"{param_name}:") - if param_meta.description: - label.setToolTip(param_meta.description) - form.addRow(label, widget) - - self.param_widgets[param_name] = widget - self.param_labels[param_name] = label - - layout.addLayout(form) - self.refresh_form() - - def refresh_form(self): - """ - Refresh the parameter widgets visibility based on - BlockMeta.is_parameter_active and current local_params. - """ - meta = self.block.instance.meta + ok_btn.setEnabled(False) + apply_btn.setEnabled(False) - for param_name, widget in self.param_widgets.items(): - label = self.param_labels[param_name] + layout.addLayout(buttons_layout) - active = meta.is_parameter_active(param_name, self.local_params) - widget.setVisible(active) - label.setVisible(active) - - # ------------------------------------------------------------ - # Buttons - # ------------------------------------------------------------ + # -------------------------------------------------------------------------- + # Button Methods + # -------------------------------------------------------------------------- def apply(self): if self.readonly: return - def get_param_value(widget: 'QWidget'): - if isinstance(widget, QComboBox): - return widget.currentText() - - if isinstance(widget, QLineEdit): - text = widget.text().strip() - if not text: - return None - try: - return ast.literal_eval(text) - except Exception: - return text - - return None - - params: dict[str, Any] = { - "name": self.name_edit.text(), - **{ - pname: get_param_value(widget) - for pname, widget in self.param_widgets.items() - } - } - + params = self.meta.gather_params(self.session) self.block.view.update_block_param_event(self.block.instance, params) + # ------------------------------------------------------------ def ok(self): self.apply() self.accept() - + # ------------------------------------------------------------ def open_help(self): help_path = self.block.instance.meta.doc_path @@ -215,45 +123,3 @@ def open_help(self): HelpDialog(help_path, self).exec() else: QMessageBox.information(self, "Help", "No documentation available.") - - - # ------------------------------------------------------------ - # internal methods - def _create_param_widget(self, - meta: ParameterMeta, - inst_params: dict[str, Any] - ): - param_name = meta.name - param_type = meta.type - value = inst_params.get(param_name) - - # ENUM - if param_type == "enum": - combo = QComboBox() - for v in meta.enum: - combo.addItem(str(v)) - if value is not None: - combo.setCurrentText(str(value)) - combo.currentTextChanged.connect( - lambda val, name=param_name: self._on_param_changed(name, val) - ) - return combo - - # SCALAR / FLOAT / INT / VECTOR / MATRIX - edit = QLineEdit() - if value is not None: - edit.setText(str(value)) - elif meta.default: - edit.setText(str(meta.default)) - edit.textChanged.connect( - lambda val, name=param_name: self._on_param_changed(name, val) - ) - - return edit - - - def _on_param_changed(self, name, value): - if self.readonly: - return - self.local_params[name] = value - self.refresh_form() diff --git a/pySimBlocks/gui/dialogs/display_yaml_dialog.py b/pySimBlocks/gui/dialogs/display_yaml_dialog.py index c671aff..fa5d0e0 100644 --- a/pySimBlocks/gui/dialogs/display_yaml_dialog.py +++ b/pySimBlocks/gui/dialogs/display_yaml_dialog.py @@ -30,7 +30,7 @@ ) from PySide6.QtGui import QFont -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.services.yaml_tools import dump_parameter_yaml, dump_model_yaml, dump_layout_yaml from pySimBlocks.gui.widgets.diagram_view import DiagramView diff --git a/pySimBlocks/gui/dialogs/plot_dialog.py b/pySimBlocks/gui/dialogs/plot_dialog.py index 0806a65..8c48900 100644 --- a/pySimBlocks/gui/dialogs/plot_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_dialog.py @@ -31,7 +31,7 @@ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.core.config import PlotConfig from pySimBlocks.project.plot_from_config import plot_from_config diff --git a/pySimBlocks/gui/dialogs/settings/plots.py b/pySimBlocks/gui/dialogs/settings/plots.py index bea481a..0687746 100644 --- a/pySimBlocks/gui/dialogs/settings/plots.py +++ b/pySimBlocks/gui/dialogs/settings/plots.py @@ -24,7 +24,7 @@ ) from PySide6.QtCore import Qt -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController diff --git a/pySimBlocks/gui/dialogs/settings/project.py b/pySimBlocks/gui/dialogs/settings/project.py index bd2335e..5b01010 100644 --- a/pySimBlocks/gui/dialogs/settings/project.py +++ b/pySimBlocks/gui/dialogs/settings/project.py @@ -24,7 +24,7 @@ QWidget, QFormLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QFileDialog, QHBoxLayout ) -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController from pySimBlocks.gui.services.project_loader import ProjectLoaderYaml diff --git a/pySimBlocks/gui/dialogs/settings/simulation.py b/pySimBlocks/gui/dialogs/settings/simulation.py index 11cf7e5..1567db2 100644 --- a/pySimBlocks/gui/dialogs/settings/simulation.py +++ b/pySimBlocks/gui/dialogs/settings/simulation.py @@ -24,7 +24,7 @@ ) from PySide6.QtCore import Qt -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController diff --git a/pySimBlocks/gui/dialogs/settings_dialog.py b/pySimBlocks/gui/dialogs/settings_dialog.py index 3efa3d8..d25f0de 100644 --- a/pySimBlocks/gui/dialogs/settings_dialog.py +++ b/pySimBlocks/gui/dialogs/settings_dialog.py @@ -25,7 +25,7 @@ from pySimBlocks.gui.dialogs.settings.project import ProjectSettingsWidget from pySimBlocks.gui.dialogs.settings.simulation import SimulationSettingsWidget from pySimBlocks.gui.dialogs.settings.plots import PlotSettingsWidget -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py index 4f358c0..2b80f11 100644 --- a/pySimBlocks/gui/graphics/block_item.py +++ b/pySimBlocks/gui/graphics/block_item.py @@ -28,7 +28,7 @@ from pySimBlocks.gui.graphics.port_item import PortItem if TYPE_CHECKING: - from pySimBlocks.gui.model.block_instance import BlockInstance + from pySimBlocks.gui.models.block_instance import BlockInstance from pySimBlocks.gui.widgets.diagram_view import DiagramView diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 573514d..4ba9221 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -23,7 +23,7 @@ from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem from pySimBlocks.gui.graphics.port_item import PortItem -from pySimBlocks.gui.model.connection_instance import ConnectionInstance +from pySimBlocks.gui.models.connection_instance import ConnectionInstance class OrthogonalRoute: diff --git a/pySimBlocks/gui/graphics/port_item.py b/pySimBlocks/gui/graphics/port_item.py index 9941401..e400070 100644 --- a/pySimBlocks/gui/graphics/port_item.py +++ b/pySimBlocks/gui/graphics/port_item.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from pySimBlocks.gui.graphics.block_item import BlockItem - from pySimBlocks.gui.model.port_instance import PortInstance + from pySimBlocks.gui.models.port_instance import PortInstance class PortItem(QGraphicsItem): diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py index e59a5ec..8ef9041 100644 --- a/pySimBlocks/gui/main_window.py +++ b/pySimBlocks/gui/main_window.py @@ -29,7 +29,7 @@ from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.dialogs.unsaved_dialog import UnsavedChangesDialog -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController from pySimBlocks.gui.services.project_loader import ProjectLoaderYaml from pySimBlocks.gui.services.project_saver import ProjectSaverYaml diff --git a/pySimBlocks/gui/model/__init__.py b/pySimBlocks/gui/models/__init__.py similarity index 82% rename from pySimBlocks/gui/model/__init__.py rename to pySimBlocks/gui/models/__init__.py index 5fa8750..f89cebb 100644 --- a/pySimBlocks/gui/model/__init__.py +++ b/pySimBlocks/gui/models/__init__.py @@ -18,10 +18,10 @@ # Authors: see Authors.txt # ****************************************************************************** -from pySimBlocks.gui.model.block_instance import BlockInstance -from pySimBlocks.gui.model.connection_instance import ConnectionInstance -from pySimBlocks.gui.model.port_instance import PortInstance -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.block_instance import BlockInstance +from pySimBlocks.gui.models.connection_instance import ConnectionInstance +from pySimBlocks.gui.models.port_instance import PortInstance +from pySimBlocks.gui.models.project_state import ProjectState __all__ = [ "BlockInstance", diff --git a/pySimBlocks/gui/model/block_instance.py b/pySimBlocks/gui/models/block_instance.py similarity index 98% rename from pySimBlocks/gui/model/block_instance.py rename to pySimBlocks/gui/models/block_instance.py index 6514063..95ed7d4 100644 --- a/pySimBlocks/gui/model/block_instance.py +++ b/pySimBlocks/gui/models/block_instance.py @@ -21,7 +21,7 @@ import uuid from typing import TYPE_CHECKING, Any, Dict, List -from pySimBlocks.gui.model.port_instance import PortInstance +from pySimBlocks.gui.models.port_instance import PortInstance try: # Python 3.11+ from typing import Self diff --git a/pySimBlocks/gui/model/connection_instance.py b/pySimBlocks/gui/models/connection_instance.py similarity index 100% rename from pySimBlocks/gui/model/connection_instance.py rename to pySimBlocks/gui/models/connection_instance.py diff --git a/pySimBlocks/gui/model/port_instance.py b/pySimBlocks/gui/models/port_instance.py similarity index 96% rename from pySimBlocks/gui/model/port_instance.py rename to pySimBlocks/gui/models/port_instance.py index ce09d11..b818e80 100644 --- a/pySimBlocks/gui/model/port_instance.py +++ b/pySimBlocks/gui/models/port_instance.py @@ -23,7 +23,7 @@ from pySimBlocks.gui.blocks.port_meta import PortMeta if TYPE_CHECKING: - from pySimBlocks.gui.model.connection_instance import ConnectionInstance + from pySimBlocks.gui.models.connection_instance import ConnectionInstance from pySimBlocks.gui.project_controller import BlockInstance class PortInstance: diff --git a/pySimBlocks/gui/model/project_simulation_params.py b/pySimBlocks/gui/models/project_simulation_params.py similarity index 100% rename from pySimBlocks/gui/model/project_simulation_params.py rename to pySimBlocks/gui/models/project_simulation_params.py diff --git a/pySimBlocks/gui/model/project_state.py b/pySimBlocks/gui/models/project_state.py similarity index 94% rename from pySimBlocks/gui/model/project_state.py rename to pySimBlocks/gui/models/project_state.py index 00277de..949e924 100644 --- a/pySimBlocks/gui/model/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -20,9 +20,9 @@ from pathlib import Path -from pySimBlocks.gui.model.block_instance import BlockInstance, PortInstance -from pySimBlocks.gui.model.connection_instance import ConnectionInstance -from pySimBlocks.gui.model.project_simulation_params import ProjectSimulationParams +from pySimBlocks.gui.models.block_instance import BlockInstance, PortInstance +from pySimBlocks.gui.models.connection_instance import ConnectionInstance +from pySimBlocks.gui.models.project_simulation_params import ProjectSimulationParams class ProjectState: def __init__(self, directory_path: Path): diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 60455a3..1e13ebb 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -25,7 +25,7 @@ from PySide6.QtCore import QObject, Signal, QPointF -from pySimBlocks.gui.model import ( +from pySimBlocks.gui.models import ( BlockInstance, ConnectionInstance, PortInstance, diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 3fbf260..c19e876 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -71,9 +71,12 @@ def _load_blocks(self, # ---- parameters ---- raw_params = params_blocks.get(name, {}) - for pname, pvalue in raw_params.items(): - if pname in block.parameters: - block.parameters[pname] = pvalue + for pmeta in block.meta.parameters: + pname = pmeta.name + if pname in raw_params: + block.parameters[pname] = raw_params[pname] + elif pmeta.autofill and pmeta.default is not None: + block.parameters[pname] = pmeta.default block.resolve_ports() controller.view.refresh_block_port(block) diff --git a/pySimBlocks/gui/services/project_saver.py b/pySimBlocks/gui/services/project_saver.py index 003aefd..e2ec36f 100644 --- a/pySimBlocks/gui/services/project_saver.py +++ b/pySimBlocks/gui/services/project_saver.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from pySimBlocks.gui.model import ProjectState +from pySimBlocks.gui.models import ProjectState from pySimBlocks.gui.graphics import BlockItem from pySimBlocks.gui.services.yaml_tools import save_yaml from pySimBlocks.project.generate_run_script import generate_python_content diff --git a/pySimBlocks/gui/services/simulation_runner.py b/pySimBlocks/gui/services/simulation_runner.py index 9bf209f..bae73b6 100644 --- a/pySimBlocks/gui/services/simulation_runner.py +++ b/pySimBlocks/gui/services/simulation_runner.py @@ -1,7 +1,7 @@ import os import shutil import sys -from pySimBlocks.gui.model import ProjectState +from pySimBlocks.gui.models import ProjectState from pySimBlocks.gui.services.yaml_tools import save_yaml from pySimBlocks.project.generate_run_script import generate_python_content diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index 7a30722..047510a 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -23,7 +23,7 @@ import yaml from pySimBlocks.gui.graphics.block_item import BlockItem -from pySimBlocks.gui.model.project_state import ProjectState +from pySimBlocks.gui.models.project_state import ProjectState def load_yaml_file(path: str) -> dict: diff --git a/pySimBlocks/gui/widgets/block_list.py b/pySimBlocks/gui/widgets/block_list.py index d8949d1..b8940e5 100644 --- a/pySimBlocks/gui/widgets/block_list.py +++ b/pySimBlocks/gui/widgets/block_list.py @@ -25,7 +25,7 @@ from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.dialogs.block_dialog import BlockDialog -from pySimBlocks.gui.model.block_instance import BlockInstance +from pySimBlocks.gui.models.block_instance import BlockInstance class _PreviewBlock: def __init__(self, instance): diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 1a41038..a4f98bc 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -28,8 +28,8 @@ from pySimBlocks.gui.graphics.connection_item import ConnectionItem, OrthogonalRoute from pySimBlocks.gui.graphics.port_item import PortItem from pySimBlocks.gui.graphics.theme import make_theme -from pySimBlocks.gui.model.block_instance import BlockInstance -from pySimBlocks.gui.model.connection_instance import ConnectionInstance +from pySimBlocks.gui.models.block_instance import BlockInstance +from pySimBlocks.gui.models.connection_instance import ConnectionInstance if TYPE_CHECKING: from pySimBlocks.gui.project_controller import ProjectController diff --git a/test/gui/test_block_meta_param_cache.py b/test/gui/test_block_meta_param_cache.py new file mode 100644 index 0000000..62df61e --- /dev/null +++ b/test/gui/test_block_meta_param_cache.py @@ -0,0 +1,16 @@ +from pySimBlocks.gui.blocks.block_dialog_session import BlockDialogSession +from pySimBlocks.gui.blocks.controllers.pid import PIDMeta +from pySimBlocks.gui.models.block_instance import BlockInstance + + +def test_gather_params_keeps_inactive_values_for_cache(): + meta = PIDMeta() + instance = BlockInstance(meta) + session = BlockDialogSession(meta, instance) + + session.local_params["controller"] = "PD" # Ki inactive for PD + session.local_params["Ki"] = 10 + + gathered = meta.gather_params(session) + + assert gathered["Ki"] == 10