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