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}