# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""Group editor widget for managing processor groups.
:Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
:Description: Provide a table-based editor to rename groups, set descriptions, and reorder
processors or nested groups without direct controller access.
"""
from __future__ import annotations
from PySide6.QtCore import QItemSelectionModel, QModelIndex, Qt, Signal
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QStyle,
QTableView,
QToolButton,
QVBoxLayout,
QWidget,
)
from mafw.steering.models import GroupConfig
from mafw.steering_gui.models import PipelineItem, SteeringTreeModel, SteeringTreeRoles
from mafw.steering_gui.utils import block_signals
[docs]
class _GroupTableView(QTableView):
"""Internal table view that exposes delete key handling."""
delete_pressed = Signal()
def keyPressEvent(self, event: QKeyEvent) -> None:
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
self.delete_pressed.emit()
event.accept()
return
super().keyPressEvent(event)
_DEFAULT_GROUP_NAME = 'Group'
"""Base name used when presenting an unnamed group."""
_DEFAULT_DESCRIPTION = ''
"""Fallback description used when none is configured."""
[docs]
class GroupEditor(QWidget):
"""Widget used to edit group metadata and the processor list."""
name_changed = Signal(str)
description_changed = Signal(str)
add_processor_requested = Signal()
add_group_requested = Signal()
edit_requested = Signal(object)
remove_requested = Signal(list)
move_requested = Signal(str, list)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._model: SteeringTreeModel | None = None
self._selection_model: QItemSelectionModel | None = None
self._current_group: PipelineItem | None = None
self._current_name: str | None = None
self._current_description: str | None = None
self._name_edit = QLineEdit()
self._description_edit = QLineEdit()
self._name_edit.setPlaceholderText('Group name')
self._description_edit.setPlaceholderText('Optional description')
self._table = _GroupTableView()
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setDragEnabled(True)
self._table.setAcceptDrops(True)
self._table.setDropIndicatorShown(True)
self._table.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self._table.verticalHeader().setVisible(False)
self._table.horizontalHeader().setVisible(False)
header = self._table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
header.setStretchLastSection(True)
self._move_up_button = QToolButton()
self._move_down_button = QToolButton()
self._edit_button = QToolButton()
self._remove_button = QToolButton()
self._apply_tool_icons()
self._add_processor_button = QPushButton('Add processor')
self._add_group_button = QPushButton('Add group')
self._add_processor_button.setToolTip('Add a processor to this group')
self._add_group_button.setToolTip('Add a nested group to this group')
self._setup_layout()
self._wire_signals()
self._update_action_state()
[docs]
def set_model(self, model: SteeringTreeModel) -> None:
"""Attach the steering tree model to the group editor."""
if self._model is not None:
self._disconnect_model_signals(self._model)
if self._selection_model is not None:
try:
self._selection_model.selectionChanged.disconnect(self._update_action_state)
except (RuntimeError, TypeError):
pass
self._model = model
self._table.setModel(model)
self._connect_model_signals(model)
self._selection_model = self._table.selectionModel()
if self._selection_model is not None:
self._selection_model.selectionChanged.connect(self._update_action_state)
self._update_action_state()
[docs]
def set_group_item(self, item: PipelineItem) -> None:
"""Display the provided group item and its processors."""
if not item.is_group() or self._model is None:
return
self._current_group = item
config = item.config
group_name = config.name if isinstance(config, GroupConfig) else _DEFAULT_GROUP_NAME
description = config.description if isinstance(config, GroupConfig) else None
self._current_name = group_name
self._current_description = description
with block_signals(self._name_edit, self._description_edit):
self._name_edit.setText(group_name or _DEFAULT_GROUP_NAME)
self._description_edit.setText(description or _DEFAULT_DESCRIPTION)
self._set_root_index(item)
self._update_action_state()
[docs]
def refresh(self) -> None:
"""Refresh the table root index after model updates."""
if self._current_group is None or self._model is None:
return
self._set_root_index(self._current_group)
self._update_action_state()
def _apply_tool_icons(self) -> None:
style = self.style()
self._move_up_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
self._move_down_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
self._edit_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView))
self._remove_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
self._move_up_button.setToolTip('Move up')
self._move_down_button.setToolTip('Move down')
self._edit_button.setToolTip('Edit')
self._remove_button.setToolTip('Delete')
def _setup_layout(self) -> None:
form = QFormLayout()
form.addRow('Name:', self._name_edit)
form.addRow('Description:', self._description_edit)
header = QLabel('Processors:')
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFrameShadow(QFrame.Shadow.Sunken)
toolbar = QHBoxLayout()
toolbar.addWidget(self._move_up_button)
toolbar.addWidget(self._move_down_button)
toolbar.addWidget(self._edit_button)
toolbar.addWidget(self._remove_button)
toolbar.addStretch(1)
add_row = QHBoxLayout()
add_row.addWidget(self._add_processor_button)
add_row.addWidget(self._add_group_button)
add_row.addStretch(1)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(header)
layout.addWidget(separator)
layout.addWidget(self._table)
layout.addLayout(toolbar)
layout.addLayout(add_row)
def _wire_signals(self) -> None:
self._name_edit.editingFinished.connect(self._on_name_changed)
self._description_edit.editingFinished.connect(self._on_description_changed)
self._table.doubleClicked.connect(self._on_edit_selected)
self._table.delete_pressed.connect(self._on_remove_selected)
self._move_up_button.clicked.connect(self._on_move_up)
self._move_down_button.clicked.connect(self._on_move_down)
self._edit_button.clicked.connect(self._on_edit_selected)
self._remove_button.clicked.connect(self._on_remove_selected)
self._add_processor_button.clicked.connect(self.add_processor_requested.emit)
self._add_group_button.clicked.connect(self.add_group_requested.emit)
def _connect_model_signals(self, model: SteeringTreeModel) -> None:
model.modelReset.connect(self.refresh)
model.layoutChanged.connect(self.refresh)
def _disconnect_model_signals(self, model: SteeringTreeModel) -> None:
for signal_name in ('modelReset', 'layoutChanged'):
signal = getattr(model, signal_name)
try:
signal.disconnect(self.refresh)
except (RuntimeError, TypeError):
pass
def _set_root_index(self, group_item: PipelineItem) -> None:
if self._model is None:
self._table.setRootIndex(QModelIndex())
return
group_index = self._find_item_index(group_item)
if group_index.isValid():
self._table.setRootIndex(group_index)
else:
self._table.setRootIndex(QModelIndex())
def _find_item_index(self, target: PipelineItem) -> QModelIndex:
if self._model is None:
return QModelIndex()
root_index = self._model.index(0, 0, QModelIndex())
for row in range(self._model.rowCount(root_index)):
candidate = self._model.index(row, 0, root_index)
found = self._find_item_index_recursive(candidate, target)
if found.isValid():
return found
return QModelIndex()
def _find_item_index_recursive(self, parent: QModelIndex, target: PipelineItem) -> QModelIndex:
if self._model is None:
return QModelIndex()
item = self._model.pipeline_item_from_index(parent)
if item is target:
return parent
for row in range(self._model.rowCount(parent)):
child = self._model.index(row, 0, parent)
found = self._find_item_index_recursive(child, target)
if found.isValid():
return found
return QModelIndex()
def _selected_items(self) -> list[PipelineItem]:
if self._model is None or self._selection_model is None:
return []
indexes = self._selection_model.selectedRows()
items: list[PipelineItem] = []
seen_ids: set[int] = set()
for index in sorted(indexes, key=lambda idx: idx.row()):
value = self._model.data(index, SteeringTreeRoles.PIPELINE_ITEM)
if isinstance(value, PipelineItem):
item_id = id(value)
if item_id in seen_ids:
continue
seen_ids.add(item_id)
items.append(value)
return items
def _update_action_state(self, *_: object) -> None:
has_selection = bool(self._selected_items())
for button in (self._move_up_button, self._move_down_button, self._edit_button, self._remove_button):
button.setEnabled(has_selection)
def _on_name_changed(self) -> None:
new_name = self._name_edit.text().strip()
if new_name == (self._current_name or ''):
return
self._current_name = new_name
self.name_changed.emit(new_name)
def _on_description_changed(self) -> None:
new_description = self._description_edit.text().strip()
if new_description == (self._current_description or ''):
return
self._current_description = new_description
self.description_changed.emit(new_description)
def _on_edit_selected(self, *_: object) -> None:
items = self._selected_items()
if not items:
return
self.edit_requested.emit(items[0])
def _on_remove_selected(self) -> None:
items = self._selected_items()
if not items:
return
self.remove_requested.emit(items)
def _on_move_up(self) -> None:
self._emit_move('up')
def _on_move_down(self) -> None:
self._emit_move('down')
def _emit_move(self, direction: str) -> None:
items = self._selected_items()
if not items:
return
self.move_requested.emit(direction, items)