use std::io;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use a2ui_base::catalog::Catalog;
use a2ui_base::event::{EventResult, InputEvent, InputKey};
use a2ui_base::message_processor::MessageProcessor;
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::protocol::server_to_client::A2uiMessage;
use crate::sample_loader::{self, Sample};
use a2ui_tui::catalogs::basic::{build_basic_catalog, build_basic_registry};
use a2ui_tui::catalogs::minimal::build_minimal_catalog;
use a2ui_tui::component_impl::ComponentRegistry;
use a2ui_tui::focus_manager::FocusManager;
use a2ui_tui::surface::SurfaceRenderer;
fn load_catalog_samples(catalog: &str) -> Vec<Sample> {
let subpath = format!("v1_0/catalogs/{catalog}/examples");
if let Ok(root) = std::env::var("A2UI_SPEC_DIR") {
sample_loader::load_samples_from_dir(&format!("{root}/{subpath}"))
} else {
sample_loader::load_samples(&subpath)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AppMode {
SampleList,
Rendered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PanelFocus {
List,
Render,
}
struct FrameData {
mode: AppMode,
samples: Vec<(String, String)>, selected_sample: usize,
messages_processed: usize,
total_messages: usize,
focused_id: Option<String>,
panel_focus: PanelFocus,
}
pub struct GalleryApp {
terminal: Terminal<CrosstermBackend<io::Stderr>>,
processor: MessageProcessor,
registry: ComponentRegistry,
catalog: Catalog,
samples: Vec<Sample>,
selected_sample: usize,
messages_processed: usize,
current_messages: Vec<A2uiMessage>,
focus_manager: FocusManager,
running: bool,
mode: AppMode,
panel_focus: PanelFocus,
list_state: ListState,
}
impl GalleryApp {
pub fn new() -> io::Result<Self> {
let backend = CrosstermBackend::new(io::stderr());
let terminal = Terminal::new(backend)?;
let basic_catalog = build_basic_catalog();
let minimal_catalog = build_minimal_catalog();
let catalog = build_basic_catalog(); let registry = build_basic_registry();
let processor = MessageProcessor::new(vec![basic_catalog, minimal_catalog]);
let mut samples = load_catalog_samples("minimal");
samples.extend(load_catalog_samples("basic"));
let mut list_state = ListState::default();
if !samples.is_empty() {
list_state.select(Some(0));
}
Ok(Self {
terminal,
processor,
registry,
catalog,
samples,
selected_sample: 0,
messages_processed: 0,
current_messages: Vec::new(),
focus_manager: FocusManager::new(),
running: true,
mode: AppMode::SampleList,
panel_focus: PanelFocus::Render,
list_state,
})
}
pub fn run(&mut self) -> io::Result<()> {
enable_raw_mode()?;
execute!(io::stderr(), EnterAlternateScreen)?;
self.terminal.clear()?;
while self.running {
let fd = self.snapshot_frame_data();
let registry = &self.registry;
let catalog = &self.catalog;
let list_state = &mut self.list_state;
let surface_ref = self.processor.model.surfaces().next();
self.terminal.draw(|frame| {
match fd.mode {
AppMode::SampleList => {
render_sample_list(frame, &fd, list_state);
}
AppMode::Rendered => {
render_split_view(
frame,
&fd,
list_state,
surface_ref,
registry,
catalog,
fd.focused_id.as_deref(),
);
}
}
})?;
if event::poll(std::time::Duration::from_millis(100))? {
let ev = event::read()?;
self.handle_event(ev);
}
}
disable_raw_mode()?;
execute!(io::stderr(), LeaveAlternateScreen)?;
Ok(())
}
fn snapshot_frame_data(&self) -> FrameData {
let samples: Vec<(String, String)> = self
.samples
.iter()
.map(|s| (s.name.clone(), s.description.clone()))
.collect();
FrameData {
mode: self.mode,
samples,
selected_sample: self.selected_sample,
messages_processed: self.messages_processed,
total_messages: self.current_messages.len(),
focused_id: self.focus_manager.focused_id().map(|s| s.to_string()),
panel_focus: self.panel_focus,
}
}
fn handle_event(&mut self, ev: Event) {
if let Event::Key(key) = ev {
if key.kind != KeyEventKind::Press {
return;
}
match self.mode {
AppMode::SampleList => self.handle_sample_list_key(key.code),
AppMode::Rendered => self.handle_rendered_key(key.code),
}
}
}
fn handle_sample_list_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('q') | KeyCode::Esc => {
self.running = false;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_sample > 0 {
self.selected_sample -= 1;
self.list_state.select(Some(self.selected_sample));
}
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.samples.is_empty() && self.selected_sample < self.samples.len() - 1 {
self.selected_sample += 1;
self.list_state.select(Some(self.selected_sample));
}
}
KeyCode::Enter => {
self.select_sample(self.selected_sample);
}
_ => {}
}
}
fn handle_rendered_key(&mut self, code: KeyCode) {
match self.panel_focus {
PanelFocus::List => self.handle_list_focus_key(code),
PanelFocus::Render => self.handle_surface_focus_key(code),
}
}
fn handle_list_focus_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('q') => self.running = false,
KeyCode::Esc => self.mode = AppMode::SampleList,
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_sample > 0 {
self.load_sample(self.selected_sample - 1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.samples.is_empty()
&& self.selected_sample < self.samples.len() - 1
{
self.load_sample(self.selected_sample + 1);
}
}
KeyCode::Enter | KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
self.panel_focus = PanelFocus::Render;
}
_ => {}
}
}
fn handle_surface_focus_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('q') => self.running = false,
KeyCode::Esc => self.panel_focus = PanelFocus::List,
KeyCode::Char('n') => {
if self.messages_processed < self.current_messages.len() {
let msg = self.current_messages[self.messages_processed].clone();
let _ = self.processor.process_message(msg);
self.messages_processed += 1;
self.rebuild_focus();
}
}
KeyCode::Char('a') => {
self.process_remaining_messages();
self.rebuild_focus();
}
KeyCode::Char('r') => {
self.replay_current_sample();
}
KeyCode::Tab => {
self.focus_manager.focus_next();
}
KeyCode::BackTab => {
self.focus_manager.focus_prev();
}
_ => {
self.dispatch_event_to_focused(code);
}
}
}
fn dispatch_event_to_focused(&mut self, code: KeyCode) {
let input_key = match code {
KeyCode::Enter => InputKey::Enter,
KeyCode::Tab => InputKey::Tab,
KeyCode::BackTab => InputKey::BackTab,
KeyCode::Up => InputKey::Up,
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
KeyCode::Backspace => InputKey::Backspace,
KeyCode::Delete => InputKey::Delete,
KeyCode::Esc => InputKey::Escape,
KeyCode::Char(' ') => InputKey::Space,
KeyCode::Char(c) => InputKey::Char(c),
_ => return,
};
let event = InputEvent::KeyPress { key: input_key };
let focused_id = match self.focus_manager.focused_id() {
Some(id) => id.to_string(),
None => return,
};
let surface = match self.processor.model.surfaces().next() {
Some(s) => s,
None => return,
};
let (comp_type, surface_id) = {
let components = surface.components.borrow();
let comp_model = match components.get(&focused_id) {
Some(m) => m,
None => return,
};
(comp_model.component_type.clone(), surface.id.clone())
};
let tui_comp = match self.registry.get(&comp_type) {
Some(c) => c,
None => return,
};
let data_model = surface.data_model.borrow();
let components = surface.components.borrow();
let catalog_functions = &self.catalog.functions;
let ctx = ComponentContext::new(
focused_id.clone(),
surface_id,
&data_model,
&components,
catalog_functions,
"",
Some(focused_id.clone()),
);
let result = tui_comp.handle_event(&ctx, &event);
drop(components);
drop(data_model);
if let Some(result) = result {
self.process_event_result(result);
}
}
fn process_event_result(&mut self, result: EventResult) {
match result {
EventResult::Action {
want_response,
response_path,
..
} => {
if want_response {
let surface_id = self
.processor
.model
.surfaces()
.next()
.map(|s| s.id.clone());
if let Some(sid) = surface_id {
let action_id = uuid::Uuid::new_v4().to_string();
let _ = self.processor.register_action(&sid, &action_id, response_path);
}
}
}
EventResult::DataUpdate { path, value } => {
if let Some(surface) = self.processor.model.surfaces_mut().next() {
surface.data_model.borrow_mut().set(&path, value);
}
}
EventResult::Toggle { path } => {
if let Some(surface) = self.processor.model.surfaces_mut().next() {
let current = surface
.data_model
.borrow()
.get(&path)
.and_then(|v| v.as_bool())
.unwrap_or(false);
surface
.data_model
.borrow_mut()
.set(&path, serde_json::json!(!current));
}
}
EventResult::Consumed => {}
}
}
fn select_sample(&mut self, index: usize) {
if index >= self.samples.len() {
return;
}
self.load_sample(index);
self.panel_focus = PanelFocus::Render;
self.mode = AppMode::Rendered;
}
fn load_sample(&mut self, index: usize) {
if index >= self.samples.len() {
return;
}
self.processor.reset();
self.current_messages = self.samples[index].messages.clone();
self.messages_processed = 0;
self.focus_manager.reset();
self.selected_sample = index;
self.list_state.select(Some(index));
self.process_remaining_messages();
self.rebuild_focus();
}
fn process_remaining_messages(&mut self) {
while self.messages_processed < self.current_messages.len() {
let msg = self.current_messages[self.messages_processed].clone();
let _ = self.processor.process_message(msg);
self.messages_processed += 1;
}
}
fn replay_current_sample(&mut self) {
let messages = self.current_messages.clone();
self.processor.reset();
self.current_messages = messages;
self.messages_processed = 0;
self.focus_manager.reset();
self.process_remaining_messages();
self.rebuild_focus();
}
fn rebuild_focus(&mut self) {
if let Some(surface) = self.processor.model.surfaces().next() {
let components = surface.components.borrow();
self.focus_manager.rebuild_from_components(&components);
}
}
}
fn render_sample_list(
frame: &mut ratatui::Frame,
fd: &FrameData,
list_state: &mut ListState,
) {
let area = frame.area();
let items: Vec<ListItem> = fd
.samples
.iter()
.enumerate()
.map(|(i, (name, desc))| {
let text_style = if i == fd.selected_sample {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let line = Line::from(vec![
Span::styled(format!(" {:>2}. ", i + 1), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} — {}", name, desc), text_style),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(" A2UI Gallery — Sample Browser "),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, list_state);
}
fn render_split_view(
frame: &mut ratatui::Frame,
fd: &FrameData,
list_state: &mut ListState,
surface: Option<&a2ui_base::model::surface_model::SurfaceModel>,
registry: &ComponentRegistry,
catalog: &Catalog,
focused_id: Option<&str>,
) {
let area = frame.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(95), Constraint::Min(1)])
.split(area);
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(outer[0]);
render_sample_list_panel(frame, fd, panels[0], list_state, fd.panel_focus == PanelFocus::List);
render_surface_panel(frame, panels[1], surface, registry, catalog, focused_id, fd.panel_focus == PanelFocus::Render);
render_help_bar(frame, outer[1], fd);
}
fn render_sample_list_panel(
frame: &mut ratatui::Frame,
fd: &FrameData,
area: Rect,
list_state: &mut ListState,
focused: bool,
) {
let items: Vec<ListItem> = fd
.samples
.iter()
.enumerate()
.map(|(i, (name, _desc))| {
let text_style = if i == fd.selected_sample {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let line = Line::from(vec![
Span::styled(format!("{:>2}. ", i + 1), Style::default().fg(Color::DarkGray)),
Span::styled(name.clone(), text_style),
]);
ListItem::new(line)
})
.collect();
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let title = if focused { " ◄ Samples " } else { " Samples " };
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, list_state);
}
fn render_surface_panel(
frame: &mut ratatui::Frame,
area: Rect,
surface: Option<&a2ui_base::model::surface_model::SurfaceModel>,
registry: &ComponentRegistry,
catalog: &Catalog,
focused_id: Option<&str>,
focused: bool,
) {
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let title = if focused { " Surface ► " } else { " Surface " };
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if let Some(surface) = surface {
let renderer = SurfaceRenderer::new(surface, registry, catalog);
renderer.render(frame, inner, focused_id);
} else {
let paragraph = Paragraph::new("No surface loaded.\nPress 'n' to step through messages.");
frame.render_widget(paragraph, inner);
}
}
fn render_help_bar(frame: &mut ratatui::Frame, area: Rect, fd: &FrameData) {
let step_info = |prefix: &str| -> String {
if fd.total_messages == 0 {
String::new()
} else {
format!("{}[{}/{}] ", prefix, fd.messages_processed, fd.total_messages)
}
};
let help_text: String = match fd.mode {
AppMode::SampleList => {
" ↑/k: up ↓/j: down Enter: select q/Esc: quit ".to_string()
}
AppMode::Rendered => match fd.panel_focus {
PanelFocus::List => format!(
" [List ◄] ↑/↓: switch sample Tab/Enter: focus surface Esc: browser q: quit {}",
step_info("")
),
PanelFocus::Render => format!(
" [Surface ►] n: step a: all r: replay Tab: cycle focus Esc: back to list q: quit {}",
step_info("")
),
},
};
let paragraph = Paragraph::new(help_text)
.style(Style::default().fg(Color::DarkGray))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}