"""Terminal User Interface for Buffalo Panel case browsing and editing."""
from __future__ import annotations
import argparse
from typing import Any, ClassVar, Literal, cast, override
import buffalo_wings.airfoil as bwa
from textual.app import App, ComposeResult
from textual.binding import BindingType
from textual.containers import Horizontal, Vertical
from textual.widgets import Footer, Header, Input, Label, Select, Static, Tree
from textual.widgets._tree import (
TreeNode, # pyright: ignore[reportPrivateImportUsage]
)
from buffalo_panel.app.gui.internal.config_templates import (
build_initial_case_mapping,
)
from buffalo_panel.app.tui.internal.simulation_data import (
AIRFOIL_ENTRY_FIELD_NAME,
AIRFOIL_TYPE_PATH_LENGTH,
NodePath,
PendingApproxAirfoilSwitch,
SimulationDataManager,
SimulationScalar,
SimulationValue,
)
from buffalo_panel.app.tui.internal.simulation_tree import (
SimulationNodeData,
SimulationTree,
)
from buffalo_panel.config import (
SchemaFieldTree,
load_panel_case,
panel_case_from_mapping,
panel_case_to_mapping,
)
from buffalo_panel.config.internal.loading import (
airfoil_mapping_source,
default_airfoil_mapping,
exact_airfoil_source_choices,
switch_airfoil_source_approx_with_metadata,
switch_airfoil_source_exact,
)
from buffalo_panel.config.internal.schema import PanelCaseSpec, SchemaClassTree
[docs]
class Panel2dTUI(App[None]):
"""TUI for browsing and editing Buffalo Panel simulations."""
CSS = """
Horizontal {
height: 1fr;
}
#value-input {
display: none;
margin-top: 1;
height: 3;
}
#value-select {
display: none;
margin-top: 1;
height: 3;
}
#hint-text {
height: 1fr;
overflow-y: scroll;
}
#config-tree {
width: 40%;
height: 100%;
border-right: solid gray;
}
#details-pane {
width: 60%;
padding: 1;
}
#details-header {
background: $primary;
color: $text;
width: 100%;
text-align: center;
margin-bottom: 1;
}
SelectOverlay {
height: auto;
max-height: 12;
}
"""
TITLE = "Buffalo Panel Case Explorer"
BINDINGS: ClassVar[list[BindingType]] = [
("q", "quit", "Quit"),
]
def __init__(
self,
filename: str | None = None,
read_only: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
if filename is None and read_only:
self.notify(
"Command line options error: "
"cannot have a read-only empty simulation."
)
self.filename = filename
self.read_only = read_only
self.manager: SimulationDataManager | None = None
self.editing_node: Any | None = None
self._setting_select_value = False
self._pending_approx_airfoil_switch: (
PendingApproxAirfoilSwitch | None
) = None
@property
def simulation_tree(self) -> Tree[SimulationNodeData]:
"""Return the simulation tree widget."""
tree_widget: Tree[Any] = self.query_one( # pyright: ignore[reportUnknownVariableType]
"#config-tree",
Tree,
)
return cast(Tree[SimulationNodeData], tree_widget)
@property
def value_select(self) -> Select[SimulationScalar]:
"""Return the scalar-choice widget."""
select_widget: Select[Any] = self.query_one( # pyright: ignore[reportUnknownVariableType]
"#value-select",
Select,
)
return cast(Select[SimulationScalar], select_widget)
[docs]
@override
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
with Horizontal():
yield SimulationTree("Simulation", id="config-tree")
with Vertical(id="details-pane"):
yield Label("Parameter Details", id="details-header")
yield Static(
"Select a node to see schema details.",
id="hint-text",
)
yield Input(placeholder="Enter new value...", id="value-input")
yield Select[SimulationScalar](
[],
prompt="Select a value...",
id="value-select",
)
yield Footer()
[docs]
def on_mount(self) -> None:
"""Initialize the tree with default simulation data on startup."""
case_spec: PanelCaseSpec | None = None
if self.filename is not None:
try:
case_spec = load_panel_case(self.filename)
self.title = (
f"Buffalo Panel Case Explorer - Editing: {self.filename}"
)
self.notify(
f"Loaded case from {self.filename}",
severity="information",
)
except Exception as exc:
self.notify(
f"Error loading file {self.filename}: {exc}",
severity="error",
)
self.filename = None
if self.filename is None or case_spec is None:
initial_mapping = build_initial_case_mapping()
case_spec = panel_case_from_mapping(initial_mapping)
self.title = "Buffalo Panel Case Explorer (Default Case)"
loaded_data = panel_case_to_mapping(case_spec)
self.manager = SimulationDataManager(
cast(dict[str, SimulationValue], loaded_data)
)
self.refresh_tree()
[docs]
def refresh_tree(self, cursor_path: NodePath | None = None) -> None:
"""Rebuild the configuration tree from current data."""
tree = cast(SimulationTree, self.simulation_tree)
if self.manager is not None:
tree.rebuild(self.manager, cursor_path=cursor_path)
def _value_at_path(self, path: NodePath) -> SimulationValue:
"""Return the current simulation value at ``path``."""
if self.manager is None:
raise ValueError("Data manager is not initialized.")
return self.manager.get_value(path)
def _is_editable_leaf(self, node_data: SimulationNodeData) -> bool:
"""Return whether the node is one editable scalar field."""
if self.read_only or self.manager is None:
return False
if not isinstance(node_data.schema, SchemaFieldTree):
return False
if node_data.path[-1] == "schema_version":
return False
return not self.manager.is_branch_value(
self.manager.get_value(node_data.path)
)
[docs]
def on_tree_node_highlighted(
self,
event: Tree.NodeHighlighted[SimulationNodeData],
) -> None:
"""Update the details pane when a node is highlighted."""
self._update_details_pane(event.node)
[docs]
def on_tree_node_selected(
self,
event: Tree.NodeSelected[SimulationNodeData],
) -> None:
"""Focus the active editor when one editable leaf is selected."""
node_data = event.node.data
if node_data is None or not self._is_editable_leaf(node_data):
return
self._clear_pending_approx_airfoil_switch_if_different(node_data.path)
self.editing_node = event.node
input_widget = self.query_one("#value-input", Input)
select_widget = self.value_select
if self._show_choice_editor(node_data, select_widget):
select_widget.focus()
return
input_widget.value = self._stringify_value(
self._value_at_path(node_data.path)
)
input_widget.display = True
input_widget.focus()
def _update_details_pane(self, node: TreeNode[SimulationNodeData]) -> None:
"""Update the details pane for the highlighted node."""
hint_pane = self.query_one("#hint-text", Static)
input_widget = self.query_one("#value-input", Input)
select_widget = self.value_select
input_widget.display = False
select_widget.display = False
node_data = node.data
if node_data is None:
hint_pane.update("Simulation root.")
self.editing_node = None
return
value = self._value_at_path(node_data.path)
self.editing_node = node if self._is_editable_leaf(node_data) else None
if node_data.is_mapping_entry:
hint_pane.update(
self._render_mapping_entry_details(node_data, value)
)
elif isinstance(node_data.schema, SchemaFieldTree):
hint_pane.update(self._render_field_details(node_data, value))
else:
hint_pane.update(
f"[bold]Class:[/] {node_data.schema.class_name}\n\n"
f"{node_data.schema.docstring or ''}"
)
if self.editing_node is None:
return
if self._show_choice_editor(node_data, select_widget):
return
input_widget.value = self._stringify_value(value)
input_widget.display = True
def _show_choice_editor(
self,
node_data: SimulationNodeData,
select_widget: Select[SimulationScalar],
) -> bool:
"""Show the choice editor when the schema offers finite choices."""
if not isinstance(node_data.schema, SchemaFieldTree):
return False
raw_choices = self._get_choices_for_field(node_data)
if raw_choices is None or len(raw_choices) == 0:
return False
options = [self._extract_choice(choice) for choice in raw_choices]
self._setting_select_value = True
try:
select_widget.set_options([
(label, cast(SimulationScalar, value))
for value, label in options
])
current_value = self._value_at_path(node_data.path)
if isinstance(current_value, bool | int | float | str) or (
current_value is None
):
select_widget.value = current_value
select_widget.display = True
finally:
self._setting_select_value = False
return True
def _get_choices_for_field(
self,
node_data: SimulationNodeData,
) -> tuple[object, ...] | None:
"""Return static or dynamic choices for one scalar field."""
schema = node_data.schema
if not isinstance(schema, SchemaFieldTree):
return None
if self.manager is not None and self.manager.is_airfoil_source_path(
node_data.path
):
airfoil_type = self.manager.airfoil_entry_type(node_data.path[:-1])
if airfoil_type is None:
return None
return exact_airfoil_source_choices(airfoil_type)
if self._is_airfoil_type_field(node_data):
return tuple(
{
"value": family.type_name,
"label": self._airfoil_family_label(family),
}
for family in bwa.AirfoilFactory.supported_families()
)
raw_choices = schema.metadata.get("choices") or schema.metadata.get(
"item_choices"
)
if isinstance(raw_choices, tuple):
return cast(tuple[object, ...], raw_choices)
if isinstance(raw_choices, list):
return tuple(cast(list[object], raw_choices))
return None
@staticmethod
def _is_airfoil_type_field(node_data: SimulationNodeData) -> bool:
"""Return whether the node edits one airfoil-family discriminator."""
return (
isinstance(node_data.schema, SchemaFieldTree)
and node_data.schema.name == "type"
and len(node_data.path) == AIRFOIL_TYPE_PATH_LENGTH
and node_data.path[0] == "geometry"
and node_data.path[1] == "bodies"
and isinstance(node_data.path[2], int)
and node_data.path[3] == AIRFOIL_ENTRY_FIELD_NAME
)
def _is_airfoil_source_field(self, node_data: SimulationNodeData) -> bool:
"""Return whether the node edits the synthetic airfoil source."""
return self.manager is not None and self.manager.is_airfoil_source_path(
node_data.path
)
@staticmethod
def _airfoil_family_label(family: bwa.AirfoilFamilyInfo) -> str:
"""Return one user-facing label for a Buffalo Wings airfoil family."""
display_name = family.display_name
if family.constructable:
return display_name
return f"{display_name} (schema only)"
[docs]
def on_select_changed(
self,
event: Select.Changed,
) -> None:
"""Apply one choice-based edit from the select widget."""
if (
self._setting_select_value
or self.editing_node is None
or self.manager is None
or not event.select.has_focus
or event.value in {Select.BLANK, Select.NULL}
):
return
node_data = self.editing_node.data
if node_data is None:
return
current_value = self._value_at_path(node_data.path)
if event.value == current_value:
return
try:
if self._is_airfoil_source_field(node_data):
if event.value not in {"designation", "params"}:
raise ValueError(
"Airfoil source selections must be "
"designation or params."
)
self._switch_airfoil_source_from_select(
node_data.path,
cast(Literal["designation", "params"], event.value),
)
elif self._is_airfoil_type_field(node_data):
if not isinstance(event.value, str):
raise ValueError("Airfoil type selections must be strings.")
self._replace_airfoil_mapping(node_data.path, event.value)
else:
self.manager.set_value(
node_data.path,
cast(SimulationScalar, event.value),
)
except Exception as exc:
self._reset_select_to_current_value(
event.select,
node_data.path,
)
self.notify(f"Update failed: {exc}", severity="error")
return
self._pending_approx_airfoil_switch = None
event.select.display = False
self.editing_node = None
self.refresh_tree(cursor_path=node_data.path)
display_name = (
"Source"
if self._is_airfoil_source_field(node_data)
else str(node_data.path[-1])
)
self.notify(f"Updated {display_name} to {event.value}")
self.simulation_tree.focus()
def _switch_airfoil_source_from_select(
self,
path: NodePath,
source: Literal["designation", "params"],
) -> None:
"""Apply one source switch, offering approximate NACA 4 fallback."""
pending = self._pending_approx_airfoil_switch
if (
pending is not None
and pending[0] == path
and pending[1] == source
and source == "designation"
and self.manager is not None
):
self.manager.apply_airfoil_mapping(
path,
cast(SimulationValue, pending[2]),
)
return
try:
self._switch_airfoil_source(path, source)
except ValueError as exc:
approx_result = self._approximate_airfoil_source_switch(
path,
source,
)
if approx_result is None:
raise
approx_mapping, approx_message = approx_result
designation = approx_mapping.get("designation")
if not isinstance(designation, str):
raise
self._pending_approx_airfoil_switch = (
path,
source,
approx_mapping,
approx_message,
)
raise ValueError(
f"{exc} Select designation again to convert "
f"approximately to {designation}. "
f"{approx_message}"
) from exc
def _approximate_airfoil_source_switch(
self,
path: NodePath,
source: Literal["designation", "params"],
) -> tuple[dict[str, SimulationValue], str] | None:
"""Return one approximate source-switch mapping when supported."""
if source != "designation":
return None
airfoil_path = path[:-1]
airfoil_value = self._value_at_path(airfoil_path)
if not isinstance(airfoil_value, dict):
return None
if (
self.manager is not None
and self.manager.airfoil_entry_type(airfoil_path) != "naca4"
):
return None
if self.manager is not None and airfoil_mapping_source(
cast(dict[str, object], self.manager.get_value(airfoil_path))
) != ("params"):
return None
approx_mapping, approx_result = (
switch_airfoil_source_approx_with_metadata(
cast(dict[str, object], airfoil_value),
source,
)
)
return (
cast(dict[str, SimulationValue], approx_mapping),
self._format_approximation_metadata(approx_result),
)
def _switch_airfoil_source(
self,
path: NodePath,
source: Literal["designation", "params"],
) -> None:
"""Switch one exact-only airfoil entry between source branches."""
if self.manager is None:
return
new_mapping = cast(
SimulationValue,
switch_airfoil_source_exact(
self._airfoil_mapping_at_path(path),
source,
),
)
self.manager.apply_airfoil_mapping(path, new_mapping)
def _airfoil_mapping_at_path(self, path: NodePath) -> dict[str, object]:
"""Return one airfoil-entry mapping for a source-switch path."""
if self.manager is None:
raise ValueError("Data manager is not initialized.")
airfoil_path = path[:-1]
airfoil_value = self.manager.get_value(airfoil_path)
if not isinstance(airfoil_value, dict):
raise TypeError("Airfoil entries must be mapping values.")
return cast(dict[str, object], airfoil_value)
def _replace_airfoil_mapping(
self,
path: NodePath,
airfoil_type: str,
) -> None:
"""Replace one airfoil entry mapping with a new family template."""
if self.manager is None:
return
new_mapping = cast(
SimulationValue,
default_airfoil_mapping(airfoil_type),
)
self.manager.apply_airfoil_mapping(path, new_mapping)
def _reset_select_to_current_value(
self,
select_widget: Select[Any],
path: NodePath,
) -> None:
"""Restore one select editor to the current backing-state value."""
self._setting_select_value = True
try:
current_value = self._value_at_path(path)
if isinstance(current_value, bool | int | float | str) or (
current_value is None
):
select_widget.value = current_value
finally:
self._setting_select_value = False
def _clear_pending_approx_airfoil_switch_if_different(
self,
path: NodePath,
) -> None:
"""Clear pending approximate switch state when focus moves away."""
pending = self._pending_approx_airfoil_switch
if pending is None:
return
if pending[0] != path:
self._pending_approx_airfoil_switch = None
@staticmethod
def _format_approximation_metadata(
result: bwa.AirfoilApproximateSwitchResult,
) -> str:
"""Render approximation metadata for one confirmation prompt."""
if not result.approximated_fields:
return "No parameter values would change."
field_labels = {
"m": "m",
"p": "p",
"max_thickness": "max_thickness",
}
parts = [
(
f"{field_labels.get(delta.field_name, delta.field_name)} "
f"{delta.requested_value:g} -> "
f"{delta.approximated_value:g}"
)
for delta in result.approximated_fields
]
return "Approximated: " + ", ".join(parts)
@staticmethod
def _render_mapping_entry_details(
node_data: SimulationNodeData,
value: SimulationValue,
) -> str:
"""Render details for one mapping entry node."""
key_label = node_data.key_label or "Name"
key_name = str(node_data.path[-1])
lines = [f"[bold]{key_label}:[/] {key_name}"]
if isinstance(node_data.schema, SchemaClassTree):
lines.append(f"[bold]Class:[/] {node_data.schema.class_name}")
if node_data.schema.docstring:
lines.append("")
lines.append(node_data.schema.docstring)
else:
lines.append(f"[bold]Type:[/] {node_data.schema.type_name}")
if node_data.schema.docstring:
lines.append("")
lines.append(node_data.schema.docstring)
lines.append("")
lines.append(f"[bold]Value Kind:[/] {type(value).__name__}")
return "\n".join(lines)
def _render_field_details(
self,
node_data: SimulationNodeData,
value: SimulationValue,
) -> str:
"""Render rich detail text for one schema field."""
schema = cast(SchemaFieldTree, node_data.schema)
lines = [
f"[bold]Name:[/] {schema.name}",
f"[bold]Value:[/] {self._stringify_value(value)}",
f"[bold]Type:[/] {schema.type_name}",
f"[bold]Required:[/] {'No' if schema.optional else 'Yes'}",
]
metadata = schema.metadata
label = metadata.get("label")
if isinstance(label, str) and label != schema.name:
lines.append(f"[bold]Label:[/] {label}")
if schema.item_type_name is not None:
lines.append(f"[bold]Item Type:[/] {schema.item_type_name}")
if metadata.get("advanced"):
lines.append("[bold]Advanced Setting:[/] Yes")
value_kind = metadata.get("value_kind")
if value_kind is not None:
lines.append(f"[bold]Value Kind:[/] {value_kind}")
reference_target = metadata.get("reference_target")
if reference_target is not None:
lines.append(f"[bold]Reference Target:[/] {reference_target}")
limits: list[str] = []
for key, label_text in [
("minimum", "min"),
("exclusive_minimum", "min (excl)"),
("maximum", "max"),
("exclusive_maximum", "max (excl)"),
]:
limit = metadata.get(key)
if limit is not None:
limits.append(f"{label_text}: {limit}")
if limits:
lines.append(f"[bold]Constraints:[/] {', '.join(limits)}")
default = metadata.get("default")
if default is not None:
lines.append(f"[bold]Default:[/] {default}")
format_hint = metadata.get("format_hint")
if format_hint is not None:
lines.append(f"[bold]Format:[/] {format_hint}")
raw_choices = self._get_choices_for_field(node_data)
if raw_choices:
choice_lines: list[str] = []
for choice in raw_choices:
choice_value, choice_label = self._extract_choice(choice)
if choice_label == choice_value:
choice_lines.append(f" - {choice_value}")
else:
choice_lines.append(f" - {choice_label} ({choice_value})")
lines.append("")
lines.append("[bold]Possible Values:[/]")
lines.extend(choice_lines)
if schema.docstring:
lines.append("")
lines.append("[bold]Description:[/]")
lines.append(schema.docstring)
return "\n".join(lines)
@staticmethod
def _extract_choice(choice: object) -> tuple[object, str]:
"""Return the underlying value and display label for one choice."""
if isinstance(choice, dict):
choice_mapping = cast(dict[str, object], choice)
value = choice_mapping.get("value")
label = choice_mapping.get("label", value)
return value, str(label)
if hasattr(choice, "value"):
rich_choice = cast(Any, choice)
value = cast(object, rich_choice.value)
label = rich_choice.label
return value, str(label)
if hasattr(choice, "name"):
rich_choice = cast(Any, choice)
name = str(rich_choice.name)
return name, name
return choice, str(choice)
@staticmethod
def _stringify_value(value: SimulationValue) -> str:
"""Render one current value for the details pane or input editor."""
if value is None:
return "None"
return str(value)
def _build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser for the tui."""
parser = argparse.ArgumentParser(
prog="panel2d-tui",
description="Text-based Buffalo Panel case builder.",
)
subparsers = parser.add_subparsers(
dest="command",
help="Available commands",
)
view_parser = subparsers.add_parser(
"view",
help="View an existing case file without modifying",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
view_parser.add_argument(
"filename",
type=str,
help="Path to the case file to view",
)
edit_parser = subparsers.add_parser(
"edit",
help="Edit an existing case file",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
edit_parser.add_argument(
"filename",
type=str,
help="Path to the case file to edit",
)
return parser
def main() -> None:
"""Entry point for the Buffalo Panel TUI application."""
parser = _build_parser()
args = parser.parse_args()
if args.command == "edit":
Panel2dTUI(filename=args.filename, read_only=False).run()
elif args.command == "view":
Panel2dTUI(filename=args.filename, read_only=True).run()
else:
Panel2dTUI().run()
if __name__ == "__main__":
main()