Source code for pySimBlocks.gui.blocks.block_meta
# ******************************************************************************
# 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 <https://www.gnu.org/licenses/>.
# ******************************************************************************
# Authors: see Authors.txt
# ******************************************************************************
import ast
import os
from abc import ABC
from pathlib import Path
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.models import BlockInstance, PortInstance
[docs]
class BlockMeta(ABC):
"""Define the GUI metadata contract for one block type.
Subclasses declare static block metadata, optional dialog customizations,
and dynamic port-resolution rules used by the GUI layer.
"""
# ----------- Mandatory class attributes (must be overridden) -----------
#: User-facing block name.
name: str
#: GUI block category.
category: str
#: Stable block type identifier.
type: str
#: Short summary displayed in the GUI.
summary: str
#: Rich description displayed in the dialog.
description: str
# ----------- Optional declarations -----------
#: Optional documentation file path, relative to the project directory.
doc_path: Path | None = None
#: Declared block parameters.
parameters: Sequence[ParameterMeta] = ()
#: Declared input port metadata.
inputs: Sequence[PortMeta] = ()
#: Declared output port metadata.
outputs: Sequence[PortMeta] = ()
# --------------------------------------------------------------------------
# Public Methods
# --------------------------------------------------------------------------
[docs]
def create_dialog_session(
self,
instance: BlockInstance,
project_dir: Path | None = None,
) -> BlockDialogSession:
"""Create a dialog session for a block instance.
Args:
instance: Block instance being edited.
project_dir: Project directory used to resolve relative files.
Returns:
New dialog session object bound to the instance.
"""
return BlockDialogSession(self, instance, project_dir)
[docs]
def is_parameter_active(self,
param_name: str,
instance_params: Dict[str, Any]) -> bool:
"""Return whether a parameter should be visible for an instance.
Args:
param_name: Parameter name to test.
instance_params: Current instance parameter values.
Returns:
True when the parameter is active.
"""
return True
[docs]
def gather_params(self, session: BlockDialogSession) -> dict[str, Any]:
"""Collect dialog parameters into a serialized parameter mapping.
Args:
session: Active dialog session.
Returns:
Parameter mapping gathered from the dialog state.
"""
# Keep full local state, including inactive params, so values are cached
# across visibility toggles and dialog reopen.
return session.local_params.copy()
[docs]
def resolve_port_group(self,
port_meta: PortMeta,
direction: Literal['input', 'output'],
instance: "BlockInstance"
) -> list["PortInstance"]:
"""Resolve one declared port group into concrete port instances.
Args:
port_meta: Declared port metadata.
direction: Direction of the port group.
instance: Block instance whose ports are being built.
Returns:
Concrete port instances for the given port group.
"""
return [PortInstance(port_meta.name, port_meta.display_as, direction, instance)]
[docs]
def build_ports(self, instance: "BlockInstance") -> list["PortInstance"]:
"""Build all concrete ports for a block instance.
Args:
instance: Block instance whose ports are being built.
Returns:
Ordered list of resolved input and output ports.
"""
ports = []
for pmeta in self.inputs:
ports.extend(self.resolve_port_group(pmeta, "input", instance))
for pmeta in self.outputs:
ports.extend(self.resolve_port_group(pmeta, "output", instance))
return ports
[docs]
def build_description(self, form: QFormLayout):
"""Build the default block description section in the dialog.
Args:
form: Form layout receiving the description widgets.
"""
title = QLabel(f"<b>{self.name}</b>")
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)
[docs]
def build_pre_param(self,
session: BlockDialogSession,
form: QFormLayout,
readonly: bool = False):
"""Build widgets shown before the standard parameter rows.
Args:
session: Active dialog session.
form: Form layout receiving the widgets.
readonly: Whether the dialog is read-only.
"""
pass
[docs]
def build_param(self,
session: BlockDialogSession,
form: QFormLayout,
readonly: bool = False):
"""Build the standard parameter widgets for the dialog.
Args:
session: Active dialog session.
form: Form layout receiving the widgets.
readonly: Whether the dialog is read-only.
"""
# --- 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
[docs]
def build_post_param(self,
session: BlockDialogSession,
form: QFormLayout,
readonly: bool = False):
"""Build widgets shown after the standard parameter rows.
Args:
session: Active dialog session.
form: Form layout receiving the widgets.
readonly: Whether the dialog is read-only.
"""
pass
[docs]
def build_file_param_row(
self,
session: BlockDialogSession,
form: QFormLayout,
pmeta: ParameterMeta,
readonly: bool = False,
file_filter: str = "Python files (*.py);;All files (*)",
) -> None:
"""Build a parameter row with a file picker button.
Args:
session: Active dialog session.
form: Form layout receiving the widgets.
pmeta: Metadata of the file parameter.
readonly: Whether the dialog is read-only.
file_filter: File picker filter string.
"""
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
[docs]
def refresh_form(self, session: BlockDialogSession):
"""Refresh widget visibility from the current local parameter state.
Args:
session: Active dialog session.
"""
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]:
"""Create the label and widget for one parameter row."""
# ENUM
if pmeta.type == "enum":
widget = self._create_enum_widget(session, pmeta, readonly)
else: # 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:
"""Create a line edit widget for one parameter."""
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:
"""Create a combo box widget for one enum parameter."""
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:
"""Open a file picker and write back a relative path when possible."""
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:
try:
relative_path = Path(os.path.relpath(str(selected_path), str(base_resolved)))
except ValueError:
# Windows cross-drive case (e.g. C: -> D:): keep absolute path.
relative_path = selected_path
edit.setText(relative_path.as_posix())
def _on_param_changed( self, val: str, name: str, session: BlockDialogSession, readonly: bool,):
"""Update local dialog state after a parameter widget changes."""
if readonly:
return
if name == "name":
session.local_params["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):
"""Apply a read-only visual style to supported widgets."""
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)