Source code for buffalo_panel.app.gui.internal.models

"""Tree models and delegates for configuration editing."""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownVariableType=false

from __future__ import annotations

from typing import Any, cast

from PyQt6 import QtCore, QtGui, QtWidgets

from buffalo_panel.app.gui.internal.utils import build_field_tooltip
from buffalo_panel.config import (
    PanelCaseSpec,
)
from buffalo_panel.config.internal.schema import (
    SchemaClassTree,
    SchemaFieldTree,
    schema_tree,
)

METADATA_ROLE = QtCore.Qt.ItemDataRole.UserRole + 1
SCHEMA_NODE_ROLE = QtCore.Qt.ItemDataRole.UserRole + 2


[docs] class SimulationModel(QtGui.QStandardItemModel): """Model to represent and edit simulation structure in a TreeView.""" @staticmethod def _append_scalar_row( parent: QtGui.QStandardItem, key_item: QtGui.QStandardItem, value: Any, meta: dict[str, Any] | None, schema_node: SchemaFieldTree | None = None, ) -> None: """ Append a row representing a scalar parameter to the parent item. Parameters ---------- parent : QtGui.QStandardItem The parent item. key_item : QtGui.QStandardItem The item representing the parameter name. value : Any The parameter value. meta : dict[str, Any] | None Metadata for the parameter. schema_node : SchemaFieldTree | None, optional Schema node for the scalar value when available. """ val_item = QtGui.QStandardItem(str(value)) tooltip = build_field_tooltip(meta) if tooltip: key_item.setToolTip(tooltip) val_item.setToolTip(tooltip) if meta is not None: key_item.setData(meta, METADATA_ROLE) if schema_node is not None: key_item.setData(schema_node, SCHEMA_NODE_ROLE) parent.appendRow([key_item, val_item]) def __init__(self, data: dict[str, Any]) -> None: """ Initialize the YAML model from a dictionary. Parameters ---------- data : dict[str, Any] The structured configuration data to populate. """ super().__init__() self.setHorizontalHeaderLabels(["Parameter", "Value"]) parent = self.invisibleRootItem() self._schema = schema_tree(PanelCaseSpec) if parent is not None: self.populate(data, parent, self._schema)
[docs] def populate( self, data: dict[str, Any] | list[Any] | str | float, parent: QtGui.QStandardItem, node: SchemaClassTree | SchemaFieldTree | None = None, ) -> None: """ Populate the tree model from nested case-schema data. This method recursively traverses the input data, creating tree nodes and attaching metadata role information used by editors. Parameters ---------- data : dict[str, Any] | list[Any] | str | float The current branch or leaf of the data structure. parent : QtGui.QStandardItem The item to which new children should be attached. node : SchemaClassTree | SchemaFieldTree | None, optional The schema definition for this branch. """ if isinstance(node, SchemaClassTree): field_map = {f.name: f for f in node.fields} if isinstance(data, dict): for field_name, field_def in field_map.items(): if field_name not in data: continue val = data[field_name] label = field_def.metadata.get("label", field_name) key_item = QtGui.QStandardItem(str(label)) key_item.setData( field_name, QtCore.Qt.ItemDataRole.UserRole ) key_item.setData(field_def.metadata, METADATA_ROLE) key_item.setData(field_def, SCHEMA_NODE_ROLE) if isinstance(val, dict | list): parent.appendRow(key_item) next_node = field_def.object_schema or field_def self.populate(val, key_item, next_node) else: self._append_scalar_row( parent, key_item, val, field_def.metadata, field_def, ) return # Handle Lists and Mappings based on item_schema item_schema = ( node.item_schema if isinstance(node, SchemaFieldTree) else None ) parent_meta = node.metadata if isinstance(node, SchemaFieldTree) else {} if isinstance(data, list): for i, val in enumerate(data): # Use collection_item_label_field if available (e.g. body 'id') label_field = parent_meta.get("collection_item_label_field") item_label = ( val.get(label_field) if isinstance(val, dict) and label_field else i ) list_key = f"[{item_label}]" key_item = QtGui.QStandardItem(list_key) key_item.setData(f"[{i}]", QtCore.Qt.ItemDataRole.UserRole) key_item.setEditable(False) key_item.setData(item_schema, SCHEMA_NODE_ROLE) if isinstance(val, dict | list): parent.appendRow(key_item) self.populate(val, key_item, item_schema) else: schema_node = ( node if isinstance(node, SchemaFieldTree) else None ) self._append_scalar_row( parent, key_item, val, parent_meta, schema_node, ) elif isinstance(data, dict): for key, val in data.items(): key_item = QtGui.QStandardItem(str(key)) key_item.setData(key, QtCore.Qt.ItemDataRole.UserRole) key_item.setData(item_schema, SCHEMA_NODE_ROLE) if isinstance(val, dict | list): parent.appendRow(key_item) self.populate(val, key_item, item_schema) else: schema_node = ( item_schema if isinstance(item_schema, SchemaFieldTree) else None ) self._append_scalar_row( parent, key_item, val, parent_meta, schema_node, )
[docs] class TypeDelegate(QtWidgets.QStyledItemDelegate): """Delegate to show specialized editors (Combo Boxes) in the tree view.""" @staticmethod def _normalize_choice(raw_choice: Any) -> tuple[object, object]: """Return one ``(value, label)`` pair from schema choice metadata.""" if isinstance(raw_choice, dict): choice = cast(dict[str, Any], raw_choice) value = choice.get("value") return value, choice.get("label", value) return raw_choice, raw_choice @classmethod def _create_choices_editor( cls, parent: QtWidgets.QWidget | None, meta: dict[str, Any] | None, ) -> QtWidgets.QComboBox | None: """ Create a combo box populated with choices from metadata. Parameters ---------- parent : QtWidgets.QWidget | None The parent widget. meta : dict[str, Any] | None Metadata containing choice definitions. Returns ------- QtWidgets.QComboBox | None The populated combo box, or None if no choices are available. """ if not meta: return None raw_choices = meta.get("choices") or meta.get("item_choices") if not isinstance(raw_choices, tuple) or not raw_choices: return None combo_box = QtWidgets.QComboBox(parent) for raw_choice in raw_choices: value, label = cls._normalize_choice(raw_choice) combo_box.addItem(str(label), value) return combo_box if combo_box.count() > 0 else None
[docs] def createEditor( # noqa: N802 self, parent: QtWidgets.QWidget | None, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex, ) -> QtWidgets.QWidget | None: """ Create a Qt editor matching the schema metadata for one field. Dispatches specialized editors (like QComboBox for enums) based on the item's metadata role or specific field names like 'airfoil'. Parameters ---------- parent : QtWidgets.QWidget | None The parent widget for the editor. option : QtWidgets.QStyleOptionViewItem Style options for the item being edited. index : QtCore.QModelIndex The model index of the item. Returns ------- QtWidgets.QWidget | None The created editor widget, or None if the field is read-only. """ key_index = index.siblingAtColumn(0) key = key_index.data(QtCore.Qt.ItemDataRole.UserRole) if key is None: key = key_index.data() if key == "schema_version": return None meta = key_index.data(METADATA_ROLE) if isinstance(meta, dict): editor = self._create_choices_editor(parent, meta) if editor is not None: return editor p_idx = key_index.parent() if ( key == "airfoil" and p_idx.isValid() and str(p_idx.data(QtCore.Qt.ItemDataRole.UserRole)).startswith("[") ): gp_idx = p_idx.parent() if ( gp_idx.isValid() and gp_idx.data(QtCore.Qt.ItemDataRole.UserRole) == "bodies" and (model := index.model()) is not None ): airfoils_root = QtCore.QModelIndex() for row in range(model.rowCount()): idx = model.index(row, 0) if idx.data(QtCore.Qt.ItemDataRole.UserRole) == "airfoils": airfoils_root = idx break if airfoils_root.isValid(): names = [ model.index(r, 0, airfoils_root).data() for r in range(model.rowCount(airfoils_root)) ] cb = QtWidgets.QComboBox(parent) cb.addItems(names) return cb return super().createEditor(parent, option, index)
[docs] def setEditorData( # noqa: N802 self, editor: QtWidgets.QWidget | None, index: QtCore.QModelIndex, ) -> None: """ Populate the editor with the current data from the model. Parameters ---------- editor : QtWidgets.QWidget | None The editor widget. index : QtCore.QModelIndex The index of the item being edited. """ if isinstance(editor, QtWidgets.QComboBox): current_value = index.data(QtCore.Qt.ItemDataRole.EditRole) choice_index = editor.findData(current_value) if choice_index >= 0: editor.setCurrentIndex(choice_index) else: editor.setCurrentText(str(current_value)) else: super().setEditorData(editor, index)
[docs] def setModelData( # noqa: N802 self, editor: QtWidgets.QWidget | None, model: QtCore.QAbstractItemModel | None, index: QtCore.QModelIndex, ) -> None: """ Validate and transfer data from the editor back to the model. Parameters ---------- editor : QtWidgets.QWidget | None The editor widget. model : QtCore.QAbstractItemModel | None The target model. index : QtCore.QModelIndex The index of the item being updated. """ choice_value: Any = None if isinstance(editor, QtWidgets.QComboBox): choice_value = editor.currentData() text = editor.currentText() elif isinstance(editor, QtWidgets.QLineEdit): text = editor.text() else: super().setModelData(editor, model, index) return if model is None: return key_index = index.siblingAtColumn(0) meta = key_index.data(METADATA_ROLE) if not meta: model.setData(index, text, QtCore.Qt.ItemDataRole.EditRole) return kind = meta.get("value_kind") min_val = meta.get("minimum") max_val = meta.get("maximum") excl_min = meta.get("exclusive_minimum") excl_max = meta.get("exclusive_maximum") display_key = key_index.data() try: val: Any if ( isinstance(editor, QtWidgets.QComboBox) and choice_value is not None ): val = choice_value elif kind == "int": val = int(text) elif kind == "float": val = float(text) elif kind == "bool": val = text.lower() == "true" else: val = text if isinstance(val, int | float): if min_val is not None and val < float(min_val): raise ValueError( f"'{display_key}' must be at least {min_val}." ) if excl_min is not None and val <= float(excl_min): raise ValueError( f"'{display_key}' must be greater than {excl_min}." ) if max_val is not None and val > float(max_val): raise ValueError( f"'{display_key}' must be no larger than {max_val}." ) if excl_max is not None and val >= float(excl_max): raise ValueError( f"'{display_key}' must be less than {excl_max}." ) model.setData(index, val, QtCore.Qt.ItemDataRole.EditRole) except ValueError as e: QtWidgets.QMessageBox.warning( editor.window(), "Invalid Input", str(e) )