Source code for mafw.steering_gui.views.processor_parameter_editor

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""Processor parameter editor for steering GUI pipelines.

:Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
:Description: Display and edit processor parameters, replica metadata, and overrides in the GUI.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable

from PySide6.QtCore import (
    QAbstractItemModel,
    QAbstractTableModel,
    QModelIndex,
    QPersistentModelIndex,
    Qt,
    Signal,
)
from PySide6.QtGui import QColor, QFont, QIcon
from PySide6.QtWidgets import (
    QAbstractItemView,
    QCheckBox,
    QDoubleSpinBox,
    QFileDialog,
    QFormLayout,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QPushButton,
    QSpinBox,
    QStyledItemDelegate,
    QStyleOptionViewItem,
    QTableView,
    QToolButton,
    QVBoxLayout,
    QWidget,
)

from mafw.steering.models import ParameterConfig, ParameterSchemaStatus, ParameterSource, ProcessorConfig
from mafw.steering_gui.utils import FLOAT_DECIMALS, FLOAT_RANGE, INT_RANGE, block_signals
from mafw.tools.regexp import parse_processor_name

_ACTIVE_COLUMN_LABEL = 'Active'
"""Header label for the parameter active toggle column."""

_COLUMNS = (
    _ACTIVE_COLUMN_LABEL,
    'Name',
    'Value',
    'Type',
    'Source',
    'Status',
)
"""Column labels for the processor parameter table."""

_RESOURCE_DIR = Path(__file__).resolve().parent.parent / 'resources'
_STATUS_ICON_FILES: dict[ParameterSchemaStatus, str] = {
    ParameterSchemaStatus.DEPRECATED: 'mynaui--danger-triangle.svg',
    ParameterSchemaStatus.OK: 'ix--namur-ok.svg',
    ParameterSchemaStatus.UNKNOWN: 'circum--square-question.svg',
    ParameterSchemaStatus.NEW: 'fluent-emoji-high-contrast--new-button.svg',
}


[docs] class _ParameterRoles: """Custom roles used to expose parameter metadata to delegates.""" PARAMETER = Qt.ItemDataRole.UserRole + 1 TYPE = Qt.ItemDataRole.UserRole + 2
[docs] @dataclass class _ParameterRow: """Container describing a single parameter table row.""" config: ParameterConfig order: int
[docs] class ParameterTableModel(QAbstractTableModel): """Qt table model exposing processor parameters for editing.""" parameter_value_changed = Signal(str, object) parameter_active_changed = Signal(str, bool, object) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._rows: list[_ParameterRow] = [] self._inherited_font = QFont() self._inherited_font.setItalic(True) self._warning_color = QColor('red') self._status_icons = { status: QIcon(str(_RESOURCE_DIR / filename)) for status, filename in _STATUS_ICON_FILES.items() } self._all_row_roles = [ Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.CheckStateRole, Qt.ItemDataRole.DecorationRole, Qt.ItemDataRole.FontRole, Qt.ItemDataRole.ForegroundRole, ]
[docs] def set_parameters(self, parameters: Iterable[ParameterConfig]) -> None: """Replace the rows with the provided parameters.""" self.beginResetModel() self._rows = [_ParameterRow(config=param, order=index) for index, param in enumerate(parameters)] self.endResetModel()
def rowCount( self, parent: QModelIndex | QPersistentModelIndex = QModelIndex(), ) -> int: # noqa: N802 if parent.isValid(): return 0 return len(self._rows) def columnCount( self, parent: QModelIndex | QPersistentModelIndex = QModelIndex(), ) -> int: # noqa: N802 if parent.isValid(): return 0 return len(_COLUMNS) def headerData( self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole, ) -> object: if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: if 0 <= section < len(_COLUMNS): return _COLUMNS[section] return None def data( self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole, ) -> object: if not index.isValid(): return None row = index.row() col = index.column() if row < 0 or row >= len(self._rows): return None config = self._rows[row].config if role == Qt.ItemDataRole.ToolTipRole: return config.help or None if role == _ParameterRoles.PARAMETER: return config if role == _ParameterRoles.TYPE: return self._effective_type(config) if role == Qt.ItemDataRole.FontRole and config.source == ParameterSource.INHERITED: return self._inherited_font if ( role == Qt.ItemDataRole.ForegroundRole and isinstance(config.status, ParameterSchemaStatus) and config.status in (ParameterSchemaStatus.UNKNOWN, ParameterSchemaStatus.DEPRECATED) ): return self._warning_color if role == Qt.ItemDataRole.CheckStateRole and col == 0: return Qt.CheckState.Checked if config.active_override else Qt.CheckState.Unchecked if role == Qt.ItemDataRole.CheckStateRole and col == 2 and self._effective_type(config) is bool: return Qt.CheckState.Checked if bool(config.value) else Qt.CheckState.Unchecked if role == Qt.ItemDataRole.DecorationRole and col == 5: icon = self._status_icons.get(config.status) if icon and not icon.isNull(): return icon if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): if col == 2 and self._effective_type(config) is bool: if role == Qt.ItemDataRole.DisplayRole: return '' return bool(config.value) return self._data_for_column(config, col, role) return None def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: if not index.isValid(): return Qt.ItemFlag.ItemIsEnabled flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable if index.column() == 0: return flags | Qt.ItemFlag.ItemIsUserCheckable if index.column() == 2: config = self._rows[index.row()].config if self._effective_type(config) is bool: return flags | Qt.ItemFlag.ItemIsUserCheckable return flags | Qt.ItemFlag.ItemIsEditable return flags def setData( self, index: QModelIndex | QPersistentModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole, ) -> bool: if not index.isValid(): return False row = index.row() col = index.column() if row < 0 or row >= len(self._rows): return False config = self._rows[row].config if col == 0 and role in (Qt.ItemDataRole.CheckStateRole, Qt.ItemDataRole.EditRole): # probably the CheckStateRole is enough. if role == Qt.ItemDataRole.EditRole and isinstance(value, bool): active = value else: active = Qt.CheckState(value) == Qt.CheckState.Checked config.active_override = active config.source = ParameterSource.CONFIG if active else ParameterSource.DEFAULT self._refresh_row(row) self.parameter_active_changed.emit(config.name, active, config.value) return True if col == 2 and role == Qt.ItemDataRole.CheckStateRole and self._effective_type(config) is bool: config.value = Qt.CheckState(value) == Qt.CheckState.Checked config.source = ParameterSource.CONFIG config.active_override = True self._refresh_row(row) self.parameter_value_changed.emit(config.name, config.value) return True if col == 2 and role == Qt.ItemDataRole.EditRole: config.value = value config.source = ParameterSource.CONFIG config.active_override = True self._refresh_row(row) self.parameter_value_changed.emit(config.name, value) return True return False def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: if column not in (0, 1): return reverse = order == Qt.SortOrder.DescendingOrder self.layoutAboutToBeChanged.emit() if column == 1: self._rows.sort(key=lambda row: row.config.name, reverse=reverse) else: self._rows.sort(key=lambda row: row.order, reverse=reverse) self.layoutChanged.emit() @staticmethod def _effective_type(config: ParameterConfig) -> Any: if config.status in (ParameterSchemaStatus.UNKNOWN, ParameterSchemaStatus.DEPRECATED): return Any return config.type @staticmethod def _format_value(value: Any) -> str: if value is None: return '' return str(value) @staticmethod def _format_type(type_hint: Any) -> str: if type_hint is Any: return 'Any' if isinstance(type_hint, type): return type_hint.__name__ return str(type_hint) def _data_for_column(self, config: ParameterConfig, col: int, role: int) -> object: if col == 0: return None if col == 1: return config.name if col == 2: return config.value if role == Qt.ItemDataRole.EditRole else self._format_value(config.value) if col == 3: return self._format_type(self._effective_type(config)) if col == 4: return config.source.value if isinstance(config.source, ParameterSource) else str(config.source) if col == 5: return config.status.value if isinstance(config.status, ParameterSchemaStatus) else str(config.status) return None def _refresh_row(self, row: int) -> None: if not (0 <= row < len(self._rows)): return first = self.index(row, 0) last = self.index(row, len(_COLUMNS) - 1) self.dataChanged.emit(first, last, self._all_row_roles)
[docs] class PathEditor(QWidget): """Inline editor combining a line edit with a browse button.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._line_edit = QLineEdit() self._browse_button = QToolButton() self._browse_button.setText('Browse') self._browse_button.clicked.connect(self._browse) layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self._line_edit) layout.addWidget(self._browse_button)
[docs] def value(self) -> str: """Return the currently selected path string.""" return self._line_edit.text()
[docs] def set_value(self, value: Any) -> None: """Update the displayed path string.""" self._line_edit.setText('' if value is None else str(value))
def _browse(self) -> None: file_path, _ = QFileDialog.getOpenFileName(self, 'Select path', self._line_edit.text()) if file_path: self._line_edit.setText(str(Path(file_path)))
[docs] class ParameterDelegate(QStyledItemDelegate): """Delegate that picks editors based on parameter type.""" def createEditor( self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex, ) -> QWidget: param_type = index.data(_ParameterRoles.TYPE) if param_type is int: spin_box = QSpinBox(parent) spin_box.setRange(*INT_RANGE) return spin_box if param_type is float: double_box = QDoubleSpinBox(parent) double_box.setRange(*FLOAT_RANGE) double_box.setDecimals(FLOAT_DECIMALS) return double_box if param_type is bool: # this is not used any more. it is left just for legacy reason and can be removed, since boolean # values are now directly rendered with a checkbox without going through the delegate. checkbox = QCheckBox(parent) checkbox.toggled.connect(self._commit_checkbox) return checkbox if param_type is Path: return PathEditor(parent) return QLineEdit(parent) def setEditorData( self, editor: QWidget, index: QModelIndex | QPersistentModelIndex, ) -> None: value = index.data(Qt.ItemDataRole.EditRole) if isinstance(editor, QSpinBox): editor.setValue(int(value) if value is not None else 0) return if isinstance(editor, QDoubleSpinBox): editor.setValue(float(value) if value is not None else 0.0) return if isinstance(editor, QCheckBox): editor.setChecked(bool(value)) return if isinstance(editor, PathEditor): editor.set_value(value) return if isinstance(editor, QLineEdit): editor.setText('' if value is None else str(value)) return super().setEditorData(editor, index) def setModelData( self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex | QPersistentModelIndex, ) -> None: if isinstance(editor, QSpinBox): model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) return if isinstance(editor, QDoubleSpinBox): model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) return if isinstance(editor, QCheckBox): model.setData(index, editor.isChecked(), Qt.ItemDataRole.EditRole) return if isinstance(editor, PathEditor): model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) return if isinstance(editor, QLineEdit): model.setData(index, editor.text(), Qt.ItemDataRole.EditRole) return super().setModelData(editor, model, index) def _commit_checkbox(self, _: bool) -> None: editor = self.sender() if isinstance(editor, QWidget): self.commitData.emit(editor) self.closeEditor.emit(editor)
[docs] class ProcessorParameterEditor(QWidget): """Widget used to edit parameters and metadata of a processor entry.""" replica_name_changed = Signal(str) new_only_changed = Signal(object) inheritance_changed = Signal(object) parameter_value_changed = Signal(str, object) parameter_active_changed = Signal(str, bool, object) reset_defaults_requested = Signal() remove_requested = Signal() edit_filters_requested = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._current_name: str | None = None self._current_replica: str | None = None self._processor_label = QLabel('Processor: -') self._replica_edit = QLineEdit() self._new_only_checkbox = QCheckBox('only new items') self._inherit_checkbox = QCheckBox('inherit from base processor') self._new_only_checkbox.setTristate(True) self._inherit_checkbox.setTristate(True) self._table_model = ParameterTableModel(self) self._table = QTableView() self._table.setModel(self._table_model) self._table.setItemDelegateForColumn(2, ParameterDelegate(self._table)) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self._table.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked) self._table.setSortingEnabled(True) header = self._table.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) header.setStretchLastSection(False) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) header.setSectionsClickable(True) self._table.verticalHeader().setVisible(False) self._reset_button = QPushButton('Reset defaults') self._remove_button = QPushButton('Remove processor') self._edit_filters_button = QPushButton('Edit filters') self._setup_layout() self._wire_signals()
[docs] def set_processor_data(self, config: ProcessorConfig) -> None: """Populate the editor with processor configuration data.""" self._current_name = config.name base_name, replica = parse_processor_name(config.name) self._current_replica = replica with block_signals(self._replica_edit, self._new_only_checkbox, self._inherit_checkbox): self._processor_label.setText(f'Processor: {base_name}') self._replica_edit.setText(replica or '') self._new_only_checkbox.setCheckState(self._encode_tristate(config.new_only)) self._inherit_checkbox.setEnabled(replica is not None) self._inherit_checkbox.setCheckState(self._encode_tristate(config.inheritance)) parameters = list(config.parameters.values()) self._table_model.set_parameters(parameters)
[docs] def current_name(self) -> str | None: """Return the currently displayed processor full name.""" return self._current_name
def _setup_layout(self) -> None: form_layout = QFormLayout() form_layout.addRow('Replica:', self._replica_edit) toggle_layout = QHBoxLayout() toggle_layout.addWidget(self._new_only_checkbox) toggle_layout.addWidget(self._inherit_checkbox) toggle_layout.addStretch(1) button_layout = QHBoxLayout() button_layout.addWidget(self._reset_button) button_layout.addWidget(self._remove_button) button_layout.addWidget(self._edit_filters_button) button_layout.addStretch(1) layout = QVBoxLayout(self) layout.addWidget(self._processor_label) layout.addLayout(form_layout) layout.addLayout(toggle_layout) layout.addWidget(self._table) layout.addLayout(button_layout) def _wire_signals(self) -> None: self._replica_edit.editingFinished.connect(self._on_replica_changed) self._new_only_checkbox.stateChanged.connect(self._on_new_only_state_changed) self._inherit_checkbox.stateChanged.connect(self._on_inheritance_state_changed) self._table_model.parameter_value_changed.connect(self.parameter_value_changed) self._table_model.parameter_active_changed.connect(self.parameter_active_changed) self._reset_button.clicked.connect(self.reset_defaults_requested) self._remove_button.clicked.connect(self.remove_requested) self._edit_filters_button.clicked.connect(self.edit_filters_requested) def _on_replica_changed(self) -> None: new_replica = self._replica_edit.text().strip() or None if new_replica == self._current_replica: return self._current_replica = new_replica self.replica_name_changed.emit(new_replica or '') @staticmethod def _resolve_tristate(state: int) -> bool | None: if Qt.CheckState(state) == Qt.CheckState.Checked: value = True elif Qt.CheckState(state) == Qt.CheckState.Unchecked: value = False else: value = None return value def _on_new_only_state_changed(self, state: int) -> None: self.new_only_changed.emit(self._resolve_tristate(state)) def _on_inheritance_state_changed(self, state: int) -> None: self.inheritance_changed.emit(self._resolve_tristate(state)) @staticmethod def _encode_tristate(value: bool | None) -> Qt.CheckState: if value: return Qt.CheckState.Checked if value is False: return Qt.CheckState.Unchecked return Qt.CheckState.PartiallyChecked