1use 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
31fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum AppMode {
49 SampleList,
51 Rendered,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum PanelFocus {
62 List,
63 Render,
64}
65
66struct FrameData {
72 mode: AppMode,
73 samples: Vec<(String, String)>, selected_sample: usize,
75 messages_processed: usize,
76 total_messages: usize,
77 focused_id: Option<String>,
78 panel_focus: PanelFocus,
80}
81
82pub struct GalleryApp {
84 terminal: Terminal<CrosstermBackend<io::Stderr>>,
86 processor: MessageProcessor,
88 registry: ComponentRegistry,
90 catalog: Catalog,
92 samples: Vec<Sample>,
94 selected_sample: usize,
96 messages_processed: usize,
98 current_messages: Vec<A2uiMessage>,
100 focus_manager: FocusManager,
102 running: bool,
104 mode: AppMode,
106 panel_focus: PanelFocus,
108 list_state: ListState,
110}
111
112impl GalleryApp {
113 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(); let registry = build_basic_registry();
122 let processor = MessageProcessor::new(vec![basic_catalog, minimal_catalog]);
123
124 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 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 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 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 disable_raw_mode()?;
195 execute!(io::stderr(), LeaveAlternateScreen)?;
196
197 Ok(())
198 }
199
200 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 fn handle_event(&mut self, ev: Event) {
225 if let Event::Key(key) = ev {
226 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 fn handle_list_focus_key(&mut self, code: KeyCode) {
271 match code {
272 KeyCode::Char('q') => self.running = false,
273 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 KeyCode::Enter | KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
289 self.panel_focus = PanelFocus::Render;
290 }
291 _ => {}
292 }
293 }
294
295 fn handle_surface_focus_key(&mut self, code: KeyCode) {
298 match code {
299 KeyCode::Char('q') => self.running = false,
300 KeyCode::Esc => self.panel_focus = PanelFocus::List,
303 KeyCode::Char('n') => {
304 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 self.process_remaining_messages();
315 self.rebuild_focus();
316 }
317 KeyCode::Char('r') => {
318 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 fn dispatch_event_to_focused(&mut self, code: KeyCode) {
339 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 let focused_id = match self.focus_manager.focused_id() {
360 Some(id) => id.to_string(),
361 None => return,
362 };
363
364 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 let tui_comp = match self.registry.get(&comp_type) {
381 Some(c) => c,
382 None => return,
383 };
384
385 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 let result = tui_comp.handle_event(&ctx, &event);
402
403 drop(components);
405 drop(data_model);
406 if let Some(result) = result {
407 self.process_event_result(result);
408 }
409 }
410
411 fn process_event_result(&mut self, result: EventResult) {
413 match result {
416 EventResult::Action {
417 want_response,
418 response_path,
419 ..
420 } => {
421 if want_response {
425 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 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 fn load_sample(&mut self, index: usize) {
482 if index >= self.samples.len() {
483 return;
484 }
485
486 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 self.process_remaining_messages();
499 self.rebuild_focus();
500 }
501
502 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 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 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
532fn 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 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
580fn 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 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 render_sample_list_panel(frame, fd, panels[0], list_state, fd.panel_focus == PanelFocus::List);
605
606 render_surface_panel(frame, panels[1], surface, registry, catalog, focused_id, fd.panel_focus == PanelFocus::Render);
608
609 render_help_bar(frame, outer[1], fd);
611}
612
613fn 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
664fn 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
700fn 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}