Skip to main content

a2ui_gallery/
app.rs

1//! Gallery application — interactive TUI for browsing and rendering A2UI samples.
2
3use std::io;
4
5use crossterm::{
6    event::{self, Event, KeyCode, KeyEventKind},
7    execute,
8    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9};
10use ratatui::{
11    Terminal,
12    backend::CrosstermBackend,
13    layout::{Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
17};
18
19use a2ui_base::catalog::Catalog;
20use a2ui_base::event::{EventResult, InputEvent, InputKey};
21use a2ui_base::message_processor::MessageProcessor;
22use a2ui_base::model::component_context::ComponentContext;
23use a2ui_base::protocol::server_to_client::A2uiMessage;
24use crate::sample_loader::{self, Sample};
25use a2ui_tui::catalogs::basic::{build_basic_catalog, build_basic_registry};
26use a2ui_tui::catalogs::minimal::build_minimal_catalog;
27use a2ui_tui::component_impl::ComponentRegistry;
28use a2ui_tui::focus_manager::FocusManager;
29use a2ui_tui::surface::SurfaceRenderer;
30
31/// Load the sample examples for a catalog (e.g. `"minimal"`, `"basic"`).
32///
33/// Uses the embedded spec tree ([`sample_loader::load_samples`]) by default so
34/// the binary is self-contained. If `A2UI_SPEC_DIR` is set, reads from that
35/// on-disk directory instead — a dev override for testing spec changes without
36/// recompiling.
37fn load_catalog_samples(catalog: &str) -> Vec<Sample> {
38    let subpath = format!("v1_0/catalogs/{catalog}/examples");
39    if let Ok(root) = std::env::var("A2UI_SPEC_DIR") {
40        sample_loader::load_samples_from_dir(&format!("{root}/{subpath}"))
41    } else {
42        sample_loader::load_samples(&subpath)
43    }
44}
45
46/// Application mode.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum AppMode {
49    /// Browsing the sample list (full screen).
50    SampleList,
51    /// Viewing a rendered sample (split panel).
52    Rendered,
53}
54
55/// Which panel owns keyboard input while in the split ([`AppMode::Rendered`]) view.
56///
57/// `List` — ↑/↓ walk the sample list and live-update the right panel.
58/// `Render` — keys dispatch to the focused component (stepper, typing, Tab focus).
59/// Esc steps `Render → List → SampleList`; Tab/Enter steps back to `Render`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum PanelFocus {
62    List,
63    Render,
64}
65
66/// Snapshot of the data needed to render a single frame.
67///
68/// This struct is extracted from `GalleryApp` before calling
69/// `terminal.draw()` to avoid borrow-checker conflicts between
70/// `self.terminal` and the draw closure.
71struct FrameData {
72    mode: AppMode,
73    samples: Vec<(String, String)>, // (name, description)
74    selected_sample: usize,
75    messages_processed: usize,
76    total_messages: usize,
77    focused_id: Option<String>,
78    /// Which split-view panel is focused (only meaningful in `Rendered` mode).
79    panel_focus: PanelFocus,
80}
81
82/// The gallery application.
83pub struct GalleryApp {
84    /// Terminal handle.
85    terminal: Terminal<CrosstermBackend<io::Stderr>>,
86    /// Message processor (owns the surface state).
87    processor: MessageProcessor,
88    /// Component registry.
89    registry: ComponentRegistry,
90    /// Catalog.
91    catalog: Catalog,
92    /// Loaded samples.
93    samples: Vec<Sample>,
94    /// Index of the currently selected sample.
95    selected_sample: usize,
96    /// How many messages have been processed for the current sample.
97    messages_processed: usize,
98    /// Messages for the current sample (cached for replay).
99    current_messages: Vec<A2uiMessage>,
100    /// Focus manager.
101    focus_manager: FocusManager,
102    /// Whether the app is still running.
103    running: bool,
104    /// Current display mode.
105    mode: AppMode,
106    /// Which split-view panel owns keyboard input (only used in `Rendered` mode).
107    panel_focus: PanelFocus,
108    /// List state for ratatui List widget highlighting.
109    list_state: ListState,
110}
111
112impl GalleryApp {
113    /// Create and initialize the gallery application.
114    pub fn new() -> io::Result<Self> {
115        let backend = CrosstermBackend::new(io::stderr());
116        let terminal = Terminal::new(backend)?;
117
118        let basic_catalog = build_basic_catalog();
119        let minimal_catalog = build_minimal_catalog();
120        let catalog = build_basic_catalog(); // real catalog for ComponentContext building
121        let registry = build_basic_registry();
122        let processor = MessageProcessor::new(vec![basic_catalog, minimal_catalog]);
123
124        // Load samples from both minimal and basic directories.
125        let mut samples = load_catalog_samples("minimal");
126        samples.extend(load_catalog_samples("basic"));
127
128        let mut list_state = ListState::default();
129        if !samples.is_empty() {
130            list_state.select(Some(0));
131        }
132
133        Ok(Self {
134            terminal,
135            processor,
136            registry,
137            catalog,
138            samples,
139            selected_sample: 0,
140            messages_processed: 0,
141            current_messages: Vec::new(),
142            focus_manager: FocusManager::new(),
143            running: true,
144            mode: AppMode::SampleList,
145            panel_focus: PanelFocus::Render,
146            list_state,
147        })
148    }
149
150    /// Run the main event loop.
151    pub fn run(&mut self) -> io::Result<()> {
152        enable_raw_mode()?;
153        execute!(io::stderr(), EnterAlternateScreen)?;
154        self.terminal.clear()?;
155
156        while self.running {
157            // Extract frame data before drawing to avoid borrow conflicts.
158            let fd = self.snapshot_frame_data();
159
160            let registry = &self.registry;
161            let catalog = &self.catalog;
162            let list_state = &mut self.list_state;
163
164            // We need a reference to the surface for rendering.
165            // Safety: we only read from processor.model during the draw.
166            let surface_ref = self.processor.model.surfaces().next();
167
168            self.terminal.draw(|frame| {
169                match fd.mode {
170                    AppMode::SampleList => {
171                        render_sample_list(frame, &fd, list_state);
172                    }
173                    AppMode::Rendered => {
174                        render_split_view(
175                            frame,
176                            &fd,
177                            list_state,
178                            surface_ref,
179                            registry,
180                            catalog,
181                            fd.focused_id.as_deref(),
182                        );
183                    }
184                }
185            })?;
186
187            if event::poll(std::time::Duration::from_millis(100))? {
188                let ev = event::read()?;
189                self.handle_event(ev);
190            }
191        }
192
193        // Restore terminal.
194        disable_raw_mode()?;
195        execute!(io::stderr(), LeaveAlternateScreen)?;
196
197        Ok(())
198    }
199
200    /// Collect a snapshot of data needed for rendering.
201    fn snapshot_frame_data(&self) -> FrameData {
202        let samples: Vec<(String, String)> = self
203            .samples
204            .iter()
205            .map(|s| (s.name.clone(), s.description.clone()))
206            .collect();
207
208        FrameData {
209            mode: self.mode,
210            samples,
211            selected_sample: self.selected_sample,
212            messages_processed: self.messages_processed,
213            total_messages: self.current_messages.len(),
214            focused_id: self.focus_manager.focused_id().map(|s| s.to_string()),
215            panel_focus: self.panel_focus,
216        }
217    }
218
219    // -----------------------------------------------------------------------
220    // Event handling
221    // -----------------------------------------------------------------------
222
223    /// Handle a single terminal event.
224    fn handle_event(&mut self, ev: Event) {
225        if let Event::Key(key) = ev {
226            // Only process key press events (ignore release).
227            if key.kind != KeyEventKind::Press {
228                return;
229            }
230            match self.mode {
231                AppMode::SampleList => self.handle_sample_list_key(key.code),
232                AppMode::Rendered => self.handle_rendered_key(key.code),
233            }
234        }
235    }
236
237    fn handle_sample_list_key(&mut self, code: KeyCode) {
238        match code {
239            KeyCode::Char('q') | KeyCode::Esc => {
240                self.running = false;
241            }
242            KeyCode::Up | KeyCode::Char('k') => {
243                if self.selected_sample > 0 {
244                    self.selected_sample -= 1;
245                    self.list_state.select(Some(self.selected_sample));
246                }
247            }
248            KeyCode::Down | KeyCode::Char('j') => {
249                if !self.samples.is_empty() && self.selected_sample < self.samples.len() - 1 {
250                    self.selected_sample += 1;
251                    self.list_state.select(Some(self.selected_sample));
252                }
253            }
254            KeyCode::Enter => {
255                self.select_sample(self.selected_sample);
256            }
257            _ => {}
258        }
259    }
260
261    fn handle_rendered_key(&mut self, code: KeyCode) {
262        match self.panel_focus {
263            PanelFocus::List => self.handle_list_focus_key(code),
264            PanelFocus::Render => self.handle_surface_focus_key(code),
265        }
266    }
267
268    /// Keys while the sample list owns focus (split view): walk samples with
269    /// live right-panel update, then hand focus to the surface to interact.
270    fn handle_list_focus_key(&mut self, code: KeyCode) {
271        match code {
272            KeyCode::Char('q') => self.running = false,
273            // Esc steps back: list focus → full-screen sample browser.
274            KeyCode::Esc => self.mode = AppMode::SampleList,
275            KeyCode::Up | KeyCode::Char('k') => {
276                if self.selected_sample > 0 {
277                    self.load_sample(self.selected_sample - 1);
278                }
279            }
280            KeyCode::Down | KeyCode::Char('j') => {
281                if !self.samples.is_empty()
282                    && self.selected_sample < self.samples.len() - 1
283                {
284                    self.load_sample(self.selected_sample + 1);
285                }
286            }
287            // Enter / Tab / → : move focus to the rendered surface.
288            KeyCode::Enter | KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
289                self.panel_focus = PanelFocus::Render;
290            }
291            _ => {}
292        }
293    }
294
295    /// Keys while the rendered surface owns focus (split view): stepper,
296    /// component focus cycling, and dispatch to the focused component.
297    fn handle_surface_focus_key(&mut self, code: KeyCode) {
298        match code {
299            KeyCode::Char('q') => self.running = false,
300            // Esc steps back: surface focus → list focus (so ↑/↓ walk samples).
301            // A second Esc (handled in list focus) returns to the sample browser.
302            KeyCode::Esc => self.panel_focus = PanelFocus::List,
303            KeyCode::Char('n') => {
304                // Process next message (stepper).
305                if self.messages_processed < self.current_messages.len() {
306                    let msg = self.current_messages[self.messages_processed].clone();
307                    let _ = self.processor.process_message(msg);
308                    self.messages_processed += 1;
309                    self.rebuild_focus();
310                }
311            }
312            KeyCode::Char('a') => {
313                // Process all remaining messages.
314                self.process_remaining_messages();
315                self.rebuild_focus();
316            }
317            KeyCode::Char('r') => {
318                // Reset and replay all messages.
319                self.replay_current_sample();
320            }
321            KeyCode::Tab => {
322                self.focus_manager.focus_next();
323            }
324            KeyCode::BackTab => {
325                self.focus_manager.focus_prev();
326            }
327            _ => {
328                self.dispatch_event_to_focused(code);
329            }
330        }
331    }
332
333    // -----------------------------------------------------------------------
334    // Event dispatch
335    // -----------------------------------------------------------------------
336
337    /// Dispatch a keyboard event to the focused component.
338    fn dispatch_event_to_focused(&mut self, code: KeyCode) {
339        // Map KeyCode to InputKey
340        let input_key = match code {
341            KeyCode::Enter => InputKey::Enter,
342            KeyCode::Tab => InputKey::Tab,
343            KeyCode::BackTab => InputKey::BackTab,
344            KeyCode::Up => InputKey::Up,
345            KeyCode::Down => InputKey::Down,
346            KeyCode::Left => InputKey::Left,
347            KeyCode::Right => InputKey::Right,
348            KeyCode::Backspace => InputKey::Backspace,
349            KeyCode::Delete => InputKey::Delete,
350            KeyCode::Esc => InputKey::Escape,
351            KeyCode::Char(' ') => InputKey::Space,
352            KeyCode::Char(c) => InputKey::Char(c),
353            _ => return,
354        };
355
356        let event = InputEvent::KeyPress { key: input_key };
357
358        // Get focused component ID
359        let focused_id = match self.focus_manager.focused_id() {
360            Some(id) => id.to_string(),
361            None => return,
362        };
363
364        // Get surface and component info
365        let surface = match self.processor.model.surfaces().next() {
366            Some(s) => s,
367            None => return,
368        };
369
370        let (comp_type, surface_id) = {
371            let components = surface.components.borrow();
372            let comp_model = match components.get(&focused_id) {
373                Some(m) => m,
374                None => return,
375            };
376            (comp_model.component_type.clone(), surface.id.clone())
377        };
378
379        // Find the TuiComponent in the registry
380        let tui_comp = match self.registry.get(&comp_type) {
381            Some(c) => c,
382            None => return,
383        };
384
385        // Build a ComponentContext
386        let data_model = surface.data_model.borrow();
387        let components = surface.components.borrow();
388        let catalog_functions = &self.catalog.functions;
389
390        let ctx = ComponentContext::new(
391            focused_id.clone(),
392            surface_id,
393            &data_model,
394            &components,
395            catalog_functions,
396            "",
397            Some(focused_id.clone()),
398        );
399
400        // Dispatch event
401        let result = tui_comp.handle_event(&ctx, &event);
402
403        // Process result — must drop borrows before mutating
404        drop(components);
405        drop(data_model);
406        if let Some(result) = result {
407            self.process_event_result(result);
408        }
409    }
410
411    /// Process an EventResult from a component.
412    fn process_event_result(&mut self, result: EventResult) {
413        // Deconstruct the result to separate data mutations from action registration,
414        // avoiding borrow conflicts with self.processor.
415        match result {
416            EventResult::Action {
417                want_response,
418                response_path,
419                ..
420            } => {
421                // Note: we intentionally do NOT eprintln here — the TUI renders
422                // into stderr, so any write would corrupt the display.
423
424                if want_response {
425                    // Get the surface ID first, then register the action separately.
426                    let surface_id = self
427                        .processor
428                        .model
429                        .surfaces()
430                        .next()
431                        .map(|s| s.id.clone());
432                    if let Some(sid) = surface_id {
433                        let action_id = uuid::Uuid::new_v4().to_string();
434                        let _ = self.processor.register_action(&sid, &action_id, response_path);
435                    }
436                }
437            }
438            EventResult::DataUpdate { path, value } => {
439                if let Some(surface) = self.processor.model.surfaces_mut().next() {
440                    surface.data_model.borrow_mut().set(&path, value);
441                }
442            }
443            EventResult::Toggle { path } => {
444                if let Some(surface) = self.processor.model.surfaces_mut().next() {
445                    let current = surface
446                        .data_model
447                        .borrow()
448                        .get(&path)
449                        .and_then(|v| v.as_bool())
450                        .unwrap_or(false);
451                    surface
452                        .data_model
453                        .borrow_mut()
454                        .set(&path, serde_json::json!(!current));
455                }
456            }
457            EventResult::Consumed => {}
458        }
459    }
460
461    // -----------------------------------------------------------------------
462    // Sample management
463    // -----------------------------------------------------------------------
464
465    /// Select a sample, switch to rendered mode, and process all messages.
466    /// Select a sample, switch to rendered mode, and process all messages.
467    fn select_sample(&mut self, index: usize) {
468        if index >= self.samples.len() {
469            return;
470        }
471        self.load_sample(index);
472        self.panel_focus = PanelFocus::Render;
473        self.mode = AppMode::Rendered;
474    }
475
476    /// Load `index`'s messages into the processor and reset interaction state.
477    ///
478    /// This is the shared core of entering a sample ([`Self::select_sample`]) and
479    /// walking the sample list while keeping the split view open (↑/↓ in list
480    /// focus). It does NOT touch `mode` or `panel_focus` — callers decide those.
481    fn load_sample(&mut self, index: usize) {
482        if index >= self.samples.len() {
483            return;
484        }
485
486        // Reset processor state for the new sample, keeping catalogs registered
487        // (resetting with empty catalogs would flag every component as unknown
488        // and pollute the TUI with warnings).
489        self.processor.reset();
490
491        self.current_messages = self.samples[index].messages.clone();
492        self.messages_processed = 0;
493        self.focus_manager.reset();
494        self.selected_sample = index;
495        self.list_state.select(Some(index));
496
497        // Process all messages at once.
498        self.process_remaining_messages();
499        self.rebuild_focus();
500    }
501
502    /// Process all remaining unprocessed messages.
503    fn process_remaining_messages(&mut self) {
504        while self.messages_processed < self.current_messages.len() {
505            let msg = self.current_messages[self.messages_processed].clone();
506            let _ = self.processor.process_message(msg);
507            self.messages_processed += 1;
508        }
509    }
510
511    /// Reset and replay all messages for the current sample.
512    fn replay_current_sample(&mut self) {
513        let messages = self.current_messages.clone();
514        self.processor.reset();
515        self.current_messages = messages;
516        self.messages_processed = 0;
517        self.focus_manager.reset();
518
519        self.process_remaining_messages();
520        self.rebuild_focus();
521    }
522
523    /// Rebuild the focus list from the first available surface.
524    fn rebuild_focus(&mut self) {
525        if let Some(surface) = self.processor.model.surfaces().next() {
526            let components = surface.components.borrow();
527            self.focus_manager.rebuild_from_components(&components);
528        }
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Free rendering functions (operate on extracted data, not on GalleryApp)
534// ---------------------------------------------------------------------------
535
536/// Render the sample list (full screen).
537fn render_sample_list(
538    frame: &mut ratatui::Frame,
539    fd: &FrameData,
540    list_state: &mut ListState,
541) {
542    let area = frame.area();
543    let items: Vec<ListItem> = fd
544        .samples
545        .iter()
546        .enumerate()
547        .map(|(i, (name, desc))| {
548            let text_style = if i == fd.selected_sample {
549                Style::default()
550                    .fg(Color::Yellow)
551                    .add_modifier(Modifier::BOLD)
552            } else {
553                Style::default()
554            };
555            // Index is always dim so the row number stays scannable regardless
556            // of selection; the name/description carry the selection styling.
557            let line = Line::from(vec![
558                Span::styled(format!(" {:>2}. ", i + 1), Style::default().fg(Color::DarkGray)),
559                Span::styled(format!("{} — {}", name, desc), text_style),
560            ]);
561            ListItem::new(line)
562        })
563        .collect();
564
565    let list = List::new(items)
566        .block(
567            Block::default()
568                .borders(Borders::ALL)
569                .title(" A2UI Gallery — Sample Browser "),
570        )
571        .highlight_style(
572            Style::default()
573                .bg(Color::DarkGray)
574                .add_modifier(Modifier::BOLD),
575        );
576
577    frame.render_stateful_widget(list, area, list_state);
578}
579
580/// Render the split view: sample list on the left, surface on the right.
581fn render_split_view(
582    frame: &mut ratatui::Frame,
583    fd: &FrameData,
584    list_state: &mut ListState,
585    surface: Option<&a2ui_base::model::surface_model::SurfaceModel>,
586    registry: &ComponentRegistry,
587    catalog: &Catalog,
588    focused_id: Option<&str>,
589) {
590    let area = frame.area();
591
592    // Split into: main panels (95%) and bottom bar (min 1 row)
593    let outer = Layout::default()
594        .direction(Direction::Vertical)
595        .constraints([Constraint::Percentage(95), Constraint::Min(1)])
596        .split(area);
597
598    let panels = Layout::default()
599        .direction(Direction::Horizontal)
600        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
601        .split(outer[0]);
602
603    // Left: compact sample list.
604    render_sample_list_panel(frame, fd, panels[0], list_state, fd.panel_focus == PanelFocus::List);
605
606    // Right: rendered surface.
607    render_surface_panel(frame, panels[1], surface, registry, catalog, focused_id, fd.panel_focus == PanelFocus::Render);
608
609    // Bottom: controls help.
610    render_help_bar(frame, outer[1], fd);
611}
612
613/// Render the sample list in a side panel (compact).
614fn render_sample_list_panel(
615    frame: &mut ratatui::Frame,
616    fd: &FrameData,
617    area: Rect,
618    list_state: &mut ListState,
619    focused: bool,
620) {
621    let items: Vec<ListItem> = fd
622        .samples
623        .iter()
624        .enumerate()
625        .map(|(i, (name, _desc))| {
626            let text_style = if i == fd.selected_sample {
627                Style::default()
628                    .fg(Color::Yellow)
629                    .add_modifier(Modifier::BOLD)
630            } else {
631                Style::default()
632            };
633            let line = Line::from(vec![
634                Span::styled(format!("{:>2}. ", i + 1), Style::default().fg(Color::DarkGray)),
635                Span::styled(name.clone(), text_style),
636            ]);
637            ListItem::new(line)
638        })
639        .collect();
640
641    let border_style = if focused {
642        Style::default().fg(Color::Yellow)
643    } else {
644        Style::default()
645    };
646    let title = if focused { " ◄ Samples " } else { " Samples " };
647
648    let list = List::new(items)
649        .block(
650            Block::default()
651                .borders(Borders::ALL)
652                .border_style(border_style)
653                .title(title),
654        )
655        .highlight_style(
656            Style::default()
657                .bg(Color::DarkGray)
658                .add_modifier(Modifier::BOLD),
659        );
660
661    frame.render_stateful_widget(list, area, list_state);
662}
663
664/// Render the current surface using SurfaceRenderer.
665///
666/// A bordered frame is always drawn so the panel's focus state is visible
667/// (yellow border + ` Surface ► ` title when focused); the surface itself is
668/// rendered into the inner area so it never overwrites the border.
669fn render_surface_panel(
670    frame: &mut ratatui::Frame,
671    area: Rect,
672    surface: Option<&a2ui_base::model::surface_model::SurfaceModel>,
673    registry: &ComponentRegistry,
674    catalog: &Catalog,
675    focused_id: Option<&str>,
676    focused: bool,
677) {
678    let border_style = if focused {
679        Style::default().fg(Color::Yellow)
680    } else {
681        Style::default()
682    };
683    let title = if focused { " Surface ► " } else { " Surface " };
684    let block = Block::default()
685        .borders(Borders::ALL)
686        .border_style(border_style)
687        .title(title);
688    let inner = block.inner(area);
689    frame.render_widget(block, area);
690
691    if let Some(surface) = surface {
692        let renderer = SurfaceRenderer::new(surface, registry, catalog);
693        renderer.render(frame, inner, focused_id);
694    } else {
695        let paragraph = Paragraph::new("No surface loaded.\nPress 'n' to step through messages.");
696        frame.render_widget(paragraph, inner);
697    }
698}
699
700/// Render the bottom help bar.
701fn render_help_bar(frame: &mut ratatui::Frame, area: Rect, fd: &FrameData) {
702    let step_info = |prefix: &str| -> String {
703        if fd.total_messages == 0 {
704            String::new()
705        } else {
706            format!("{}[{}/{}] ", prefix, fd.messages_processed, fd.total_messages)
707        }
708    };
709
710    let help_text: String = match fd.mode {
711        AppMode::SampleList => {
712            " ↑/k: up  ↓/j: down  Enter: select  q/Esc: quit ".to_string()
713        }
714        AppMode::Rendered => match fd.panel_focus {
715            PanelFocus::List => format!(
716                " [List ◄] ↑/↓: switch sample  Tab/Enter: focus surface  Esc: browser  q: quit {}",
717                step_info("")
718            ),
719            PanelFocus::Render => format!(
720                " [Surface ►] n: step  a: all  r: replay  Tab: cycle focus  Esc: back to list  q: quit {}",
721                step_info("")
722            ),
723        },
724    };
725
726    let paragraph = Paragraph::new(help_text)
727        .style(Style::default().fg(Color::DarkGray))
728        .wrap(Wrap { trim: false });
729    frame.render_widget(paragraph, area);
730}