use std::io::{self, stdout};
use std::path::PathBuf;
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget},
};
use tazuna::tui::components::{ButtonRow, SelectableList, TextInput, button_presets};
use tazuna::tui::popup::{IssueItem, SessionItem, WorktreeItem};
use tazuna::tui::status_bar::StatusBar;
use tazuna::tui::tabs::StatusIndicator;
use tazuna::worktree::GitWorktreeStatus;
type RenderFn = Box<dyn Fn(Rect, &mut ratatui::buffer::Buffer)>;
struct ComponentEntry {
name: &'static str,
variants: Vec<(&'static str, RenderFn)>,
}
fn main() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>) -> io::Result<()> {
let catalog = build_catalog();
let mut component_idx = 0;
let mut variant_idx = 0;
let mut list_state = ListState::default();
list_state.select(Some(0));
loop {
terminal.draw(|f| {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(1)])
.split(area);
render_component_list(f, chunks[0], &catalog, component_idx, &mut list_state);
let preview_area = chunks[1];
let preview_block = Block::default()
.title(format!(
" {} - {} ",
catalog[component_idx].name, catalog[component_idx].variants[variant_idx].0
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = preview_block.inner(preview_area);
f.render_widget(preview_block, preview_area);
let buf = f.buffer_mut();
(catalog[component_idx].variants[variant_idx].1)(inner, buf);
})?;
if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('j') | KeyCode::Down => {
component_idx = (component_idx + 1) % catalog.len();
variant_idx = 0;
list_state.select(Some(component_idx));
}
KeyCode::Char('k') | KeyCode::Up => {
component_idx = if component_idx == 0 {
catalog.len() - 1
} else {
component_idx - 1
};
variant_idx = 0;
list_state.select(Some(component_idx));
}
KeyCode::Char('l') | KeyCode::Right => {
variant_idx = (variant_idx + 1) % catalog[component_idx].variants.len();
}
KeyCode::Char('h') | KeyCode::Left => {
let len = catalog[component_idx].variants.len();
variant_idx = if variant_idx == 0 {
len - 1
} else {
variant_idx - 1
};
}
_ => {}
}
}
}
}
fn render_component_list(
f: &mut ratatui::Frame,
area: Rect,
catalog: &[ComponentEntry],
selected: usize,
state: &mut ListState,
) {
let items: Vec<ListItem> = catalog
.iter()
.enumerate()
.map(|(i, entry)| {
let style = if i == selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(entry.name, style)))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(" Components (j/k) ")
.borders(Borders::ALL),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, state);
let help =
Paragraph::new("h/l: variants | q: quit").style(Style::default().fg(Color::DarkGray));
let help_area = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
f.render_widget(help, help_area);
}
#[allow(clippy::too_many_lines)]
fn build_catalog() -> Vec<ComponentEntry> {
vec![
ComponentEntry {
name: "TextInput",
variants: vec![
(
"focused",
Box::new(|area, buf| {
let widget =
TextInput::new("feature/auth", 12, "Branch Name").focused(true);
widget.render(area, buf);
}),
),
(
"unfocused",
Box::new(|area, buf| {
let widget =
TextInput::new("feature/auth", 12, "Branch Name").focused(false);
widget.render(area, buf);
}),
),
(
"empty",
Box::new(|area, buf| {
let widget = TextInput::new("", 0, "Enter text...").focused(true);
widget.render(area, buf);
}),
),
],
},
ComponentEntry {
name: "SelectableList",
variants: vec![
(
"sessions focused",
Box::new(|area, buf| {
let items = vec![
SessionItem::new("session-1", StatusIndicator::Running)
.branch(Some("feature/auth".to_string())),
SessionItem::new("session-2", StatusIndicator::Terminated),
SessionItem::new("session-3", StatusIndicator::Running).pending(true),
];
let list =
SelectableList::new(&items, "Sessions (Enter: switch)").focused(true);
let mut state = ListState::default();
state.select(Some(0));
list.render(area, buf, &mut state);
}),
),
(
"sessions unfocused",
Box::new(|area, buf| {
let items = vec![
SessionItem::new("session-1", StatusIndicator::Running),
SessionItem::new("session-2", StatusIndicator::Terminated),
];
let list = SelectableList::new(&items, "Sessions").focused(false);
let mut state = ListState::default();
state.select(Some(0));
list.render(area, buf, &mut state);
}),
),
(
"worktrees",
Box::new(|area, buf| {
let items = vec![
WorktreeItem::new(
"feature/auth",
PathBuf::from("/worktrees/abc12345"),
GitWorktreeStatus {
dirty: true,
..Default::default()
},
),
WorktreeItem::new(
"main",
PathBuf::from("/worktrees/def67890"),
GitWorktreeStatus {
ahead: 2,
behind: 1,
..Default::default()
},
),
];
let list = SelectableList::new(&items, "Worktrees").focused(true);
let mut state = ListState::default();
state.select(Some(0));
list.render(area, buf, &mut state);
}),
),
(
"issues",
Box::new(|area, buf| {
let items = vec![
IssueItem::new(42, "Add authentication flow", "enhancement"),
IssueItem::new(17, "Fix crash on startup", "bug, critical"),
IssueItem::new(99, "Update documentation", ""),
];
let list = SelectableList::new(&items, "Issues").focused(true);
let mut state = ListState::default();
state.select(Some(1));
list.render(area, buf, &mut state);
}),
),
(
"empty",
Box::new(|area, buf| {
let items: Vec<SessionItem> = vec![];
let list = SelectableList::new(&items, "Empty List").focused(true);
let mut state = ListState::default();
list.render(area, buf, &mut state);
}),
),
],
},
ComponentEntry {
name: "ButtonRow",
variants: vec![
(
"yes selected",
Box::new(|area, buf| {
let buttons =
ButtonRow::new(&[button_presets::YES, button_presets::NO]).selected(0);
buttons.render(area, buf);
}),
),
(
"no selected",
Box::new(|area, buf| {
let buttons =
ButtonRow::new(&[button_presets::YES, button_presets::NO]).selected(1);
buttons.render(area, buf);
}),
),
(
"extended buttons",
Box::new(|area, buf| {
let buttons = ButtonRow::new(&[
button_presets::YES_UNDERSTAND,
button_presets::NO_CANCEL,
])
.selected(0);
buttons.render(area, buf);
}),
),
],
},
ComponentEntry {
name: "StatusBar",
variants: vec![
(
"minimal",
Box::new(|area, buf| {
let status = StatusBar::new(3);
status.render(area, buf);
}),
),
(
"with session",
Box::new(|area, buf| {
let status = StatusBar::new(2).active_session("feature-auth");
status.render(area, buf);
}),
),
(
"with branch",
Box::new(|area, buf| {
let status = StatusBar::new(2)
.active_session("my-session")
.active_branch("main");
status.render(area, buf);
}),
),
(
"with notifications",
Box::new(|area, buf| {
let status = StatusBar::new(1).notifications(5);
status.render(area, buf);
}),
),
(
"with cost",
Box::new(|area, buf| {
let status = StatusBar::new(1).today_cost(7.50);
status.render(area, buf);
}),
),
(
"full",
Box::new(|area, buf| {
let status = StatusBar::new(5)
.active_session("dev-session")
.active_branch("feature/login")
.notifications(3)
.today_cost(12.34);
status.render(area, buf);
}),
),
],
},
]
}