# 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