Skip to main content

a2ui_tui/
interaction.rs

1//! Reusable keyboard-interaction helpers for A2UI TUI applications.
2//!
3//! The gallery app (`a2ui::gallery::app`) was the first place a complete,
4//! validated event-dispatch pipeline was implemented. Several example programs
5//! duplicate that ~40-line dispatch+apply boilerplate (and a couple carry bugs
6//! where an `EventResult` is dropped). This module extracts the gallery's
7//! semantics into small public functions so any app can replace its hand-rolled
8//! copy with a single call to [`handle_key`] (or the granular pieces).
9//!
10//! The logic here mirrors `gallery::app::GalleryApp`'s
11//! `dispatch_event_to_focused` / `process_event_result` methods exactly — it
12//! does not introduce new behavior.
13
14use crossterm::event::KeyCode;
15
16use a2ui_base::catalog::Catalog;
17use a2ui_base::event::{EventResult, InputEvent, InputKey};
18use a2ui_base::message_processor::MessageProcessor;
19use a2ui_base::model::component_context::ComponentContext;
20use crate::component_impl::ComponentRegistry;
21use crate::focus_manager::FocusManager;
22
23/// Map a crossterm [`KeyCode`] to the framework-agnostic [`InputKey`].
24///
25/// Returns `None` for keys the A2UI model does not model (e.g. modifier-only
26/// presses). Mirrors the `match` in `gallery::app::dispatch_event_to_focused`.
27pub fn map_key_code(code: KeyCode) -> Option<InputKey> {
28    let key = match code {
29        KeyCode::Enter => InputKey::Enter,
30        KeyCode::Tab => InputKey::Tab,
31        KeyCode::BackTab => InputKey::BackTab,
32        KeyCode::Up => InputKey::Up,
33        KeyCode::Down => InputKey::Down,
34        KeyCode::Left => InputKey::Left,
35        KeyCode::Right => InputKey::Right,
36        KeyCode::Backspace => InputKey::Backspace,
37        KeyCode::Delete => InputKey::Delete,
38        KeyCode::Esc => InputKey::Escape,
39        KeyCode::Char(' ') => InputKey::Space,
40        KeyCode::Char(c) => InputKey::Char(c),
41        _ => return None,
42    };
43    Some(key)
44}
45
46/// Dispatch an already-built [`InputEvent`] to the focused component and return
47/// whatever [`EventResult`] it produces.
48///
49/// This is `gallery::app::dispatch_event_to_focused` with the `KeyCode →
50/// InputKey` mapping factored out (see [`map_key_code`]). It:
51///
52/// 1. Reads the focused component id from `focus`; returns `None` if nothing is
53///    focused.
54/// 2. Takes the first surface from the processor's surface group; returns
55///    `None` if there are no surfaces.
56/// 3. Looks the focused id up in that surface's components model to find its
57///    `component_type`; returns `None` if the id is unknown.
58/// 4. Looks the component type up in the `registry`; returns `None` if the type
59///    has no TUI implementation.
60/// 5. Builds a [`ComponentContext`] (empty `base_path`, focused id set) and
61///    calls [`TuiComponent::handle_event`](crate::component_impl::TuiComponent::handle_event).
62///
63/// All borrows on the surface's `data_model` / `components` are dropped before
64/// the function returns, so the returned [`EventResult`] is fully owned.
65pub fn dispatch_to_focused(
66    processor: &MessageProcessor,
67    registry: &ComponentRegistry,
68    catalog: &Catalog,
69    focus: &FocusManager,
70    event: &InputEvent,
71) -> Option<EventResult> {
72    // 1. Focused component id.
73    let focused_id = focus.focused_id()?.to_string();
74
75    // 2. First surface.
76    let surface = processor.model.surfaces().next()?;
77
78    // 3. Resolve the focused component's type (drop the borrow before returning).
79    let surface_id = surface.id.clone();
80    let (comp_type, has_component) = {
81        let components = surface.components.borrow();
82        match components.get(&focused_id) {
83            Some(m) => (m.component_type.clone(), true),
84            None => (String::new(), false),
85        }
86    };
87    if !has_component {
88        return None;
89    }
90
91    // 4. TUI implementation for this type.
92    let tui_comp = registry.get(&comp_type)?;
93
94    // 5. Build context and dispatch.
95    let data_model = surface.data_model.borrow();
96    let components = surface.components.borrow();
97    let catalog_functions = &catalog.functions;
98
99    let ctx = ComponentContext::new(
100        focused_id.clone(),
101        surface_id,
102        &data_model,
103        &components,
104        catalog_functions,
105        "",
106        Some(focused_id.clone()),
107    );
108
109    let result = tui_comp.handle_event(&ctx, event);
110
111    // Drop borrows before returning so the caller is free to mutate the
112    // processor (mirrors the gallery's explicit `drop(...)` calls).
113    drop(components);
114    drop(data_model);
115
116    result
117}
118
119/// Apply an [`EventResult`] produced by a component to the processor's state.
120///
121/// Re-exported from [`a2ui_base::interaction::apply_event_result`]
122/// (framework-agnostic) so every backend shares one implementation. Kept here
123/// under the historical `a2ui_tui::interaction::apply_event_result` path so
124/// existing callers keep compiling.
125pub use a2ui_base::interaction::apply_event_result;
126
127/// The one-call keyboard pipeline: map a [`KeyCode`], dispatch it to the
128/// focused component, and apply the resulting [`EventResult`].
129///
130/// Equivalent to the gallery's `dispatch_event_to_focused` immediately
131/// followed by `process_event_result`. Returns the action `response_path`
132/// (if any) so the caller can send the action and await a response.
133///
134/// The sequential borrows compile cleanly: [`dispatch_to_focused`] takes
135/// `&processor` and returns an owned [`EventResult`], ending the shared borrow
136/// before [`apply_event_result`] takes `&mut processor`.
137pub fn handle_key(
138    processor: &mut MessageProcessor,
139    registry: &ComponentRegistry,
140    catalog: &Catalog,
141    focus: &FocusManager,
142    code: KeyCode,
143) -> Option<String> {
144    let key = map_key_code(code)?;
145    let event = InputEvent::KeyPress { key };
146    let result = dispatch_to_focused(processor, registry, catalog, focus, &event)?;
147    apply_event_result(processor, result)
148}