Source code for mafw.steering_gui.views.group_editor

#  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)