Source code for buffalo_panel.app.tui.panel2d_tui

"""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_input_submitted(self, event: Input.Submitted) -> None: """Apply one typed text edit from the input widget.""" if self.editing_node is None or self.manager is None: return node_data = self.editing_node.data if node_data is None or not isinstance( node_data.schema, SchemaFieldTree ): return try: new_value = self.manager.coerce_value(event.value, node_data.schema) self.manager.set_value(node_data.path, new_value) except Exception as exc: self.notify(f"Invalid input: {exc}", severity="error") return self._pending_approx_airfoil_switch = None event.input.display = False self.editing_node = None self.refresh_tree(cursor_path=node_data.path) self.notify(f"Updated {node_data.path[-1]} to {new_value}") self.simulation_tree.focus()
[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()