use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use super::app::{App, Entry, Mode};
use super::theme::Theme;
const WIDE_BREAKPOINT: u16 = 100;
pub fn draw(frame: &mut Frame, app: &mut App) {
let outer = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(frame.area());
draw_title(frame, outer[0], app);
draw_body(frame, outer[1], app);
draw_status(frame, outer[2], app);
if app.help_visible {
draw_help(frame, &app.theme);
}
}
fn draw_title(frame: &mut Frame, area: Rect, app: &App) {
let count = app.visible.len();
let total = app.entries.len();
let cursor = if count == 0 {
format!("0/{total}")
} else {
format!("{}/{}", app.selected + 1, count)
};
let filtered = if count == total {
String::new()
} else {
format!(" of {total}")
};
let scope = match app.mode {
Mode::SingleRepo => "single",
Mode::CrossRepo => "cross-repo",
};
let title = Paragraph::new(Line::from(vec![
Span::styled("limb", app.theme.accent()),
Span::raw(" · worktree picker "),
Span::styled(format!("({scope})"), app.theme.muted()),
Span::raw(" "),
Span::styled(format!("{cursor}{filtered}"), app.theme.muted()),
]));
frame.render_widget(title, area);
}
fn draw_body(frame: &mut Frame, area: Rect, app: &mut App) {
if area.width >= WIDE_BREAKPOINT {
let panes = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
draw_list(frame, panes[0], app);
draw_preview(frame, panes[1], app);
} else {
draw_list(frame, area, app);
}
}
fn draw_list(frame: &mut Frame, area: Rect, app: &App) {
let cols = column_widths(app);
let items: Vec<ListItem<'_>> = app
.visible
.iter()
.filter_map(|ix| app.entries.get(*ix))
.map(|e| ListItem::new(row_line(e, app.mode, &cols, &app.theme)))
.collect();
let title = if app.visible.is_empty() {
" worktrees. No matches ".to_string()
} else {
format!(" worktrees. {} ", app.visible.len())
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(app.theme.border())
.title(Span::styled(title, app.theme.title()));
let list = List::new(items)
.block(block)
.highlight_style(app.theme.selected())
.highlight_symbol("› ");
let mut state = ListState::default();
if !app.visible.is_empty() {
state.select(Some(app.selected));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_preview(frame: &mut Frame, area: Rect, app: &mut App) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(app.theme.border())
.title(Span::styled(" preview ", app.theme.title()));
let Some(entry) = app.selected_entry().cloned() else {
let empty = Paragraph::new(Line::from(Span::styled(
"(nothing selected)",
app.theme.muted(),
)))
.block(block);
frame.render_widget(empty, area);
return;
};
let preview = app.preview.get_or_compute(&entry.worktree.path).clone();
let theme = &app.theme;
let mut lines: Vec<Line<'_>> = vec![
row(
theme,
"repo",
entry.repo.clone().unwrap_or_else(|| "-".into()),
),
row(theme, "name", entry.worktree.name.clone()),
row(
theme,
"branch",
entry
.worktree
.branch
.clone()
.unwrap_or_else(|| "(detached)".into()),
),
row(theme, "path", entry.worktree.path.display().to_string()),
];
if let Some(u) = &entry.upstream {
lines.push(row(theme, "upstream", u.name.clone()));
let arrow_line = Line::from(vec![
Span::styled(format!("{:<12}", "ahead/behind"), theme.muted()),
Span::styled(format!("↑{} ", u.ahead), theme.ahead()),
Span::styled(format!("↓{}", u.behind), theme.behind()),
]);
lines.push(arrow_line);
} else {
lines.push(row(theme, "upstream", "-"));
}
let dirty_line = Line::from(vec![
Span::styled(format!("{:<12}", "dirty"), theme.muted()),
match entry.dirty {
0 => Span::styled("clean", theme.clean()),
n => Span::styled(format!("{n} file(s)"), theme.dirty()),
},
]);
lines.push(dirty_line);
if !preview.commits.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled("recent commits", theme.muted()));
for c in &preview.commits {
lines.push(Line::raw(c.clone()));
}
}
if !preview.diff_stat.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled("uncommitted changes", theme.muted()));
for l in &preview.diff_stat {
lines.push(Line::raw(l.clone()));
}
}
let p = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(p, area);
}
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let hints = if app.filter_active {
Line::from(vec![
Span::raw("/"),
Span::styled(app.fuzzy.query().to_string(), app.theme.accent()),
Span::raw("█ "),
key(&app.theme, "Enter"),
Span::raw(" select "),
key(&app.theme, "Esc"),
Span::raw(" clear"),
])
} else {
Line::from(vec![
key(&app.theme, "Enter"),
Span::raw(" pick "),
key(&app.theme, "/"),
Span::raw(" filter "),
key(&app.theme, "?"),
Span::raw(" help "),
key(&app.theme, "Esc"),
Span::raw(" cancel"),
])
};
let p = Paragraph::new(hints).style(app.theme.muted());
frame.render_widget(p, area);
}
fn draw_help(frame: &mut Frame, theme: &Theme) {
let area = frame.area();
let width = 44.min(area.width.saturating_sub(4));
let height = 15.min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let rect = Rect::new(x, y, width, height);
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.accent())
.title(Span::styled(" help ", theme.title()));
let lines: Vec<Line<'_>> = vec![
help_row(theme, "j / ↓", "move down"),
help_row(theme, "k / ↑", "move up"),
help_row(theme, "g / Home", "top"),
help_row(theme, "G / End", "bottom"),
help_row(theme, "PgUp/PgDn", "page"),
help_row(theme, "Enter", "select"),
help_row(theme, "/", "fuzzy filter"),
help_row(theme, "Esc", "cancel / close"),
help_row(theme, "q", "cancel"),
help_row(theme, "Ctrl-C", "cancel"),
help_row(theme, "?", "toggle this help"),
];
let p = Paragraph::new(lines)
.block(block)
.alignment(Alignment::Left);
frame.render_widget(p, rect);
}
struct Cols {
repo: usize,
name: usize,
branch: usize,
}
fn column_widths(app: &App) -> Cols {
let repo = match app.mode {
Mode::CrossRepo => app
.entries
.iter()
.map(|e| e.repo.as_deref().map_or(0, str::len))
.max()
.unwrap_or(0)
.max(4),
Mode::SingleRepo => 0,
};
let name = app
.entries
.iter()
.map(|e| e.worktree.name.len())
.max()
.unwrap_or(0)
.max(4);
let branch = app
.entries
.iter()
.map(|e| e.worktree.branch.as_deref().unwrap_or("(detached)").len())
.max()
.unwrap_or(0)
.max(6);
Cols { repo, name, branch }
}
fn row_line(entry: &Entry, mode: Mode, cols: &Cols, theme: &Theme) -> Line<'static> {
let branch = entry.worktree.branch.as_deref().unwrap_or("(detached)");
let dirty_style = if entry.dirty == 0 {
theme.muted()
} else {
theme.dirty()
};
let dirty = if entry.dirty == 0 {
"·".to_string()
} else {
entry.dirty.to_string()
};
let mut spans: Vec<Span<'static>> = Vec::new();
if mode == Mode::CrossRepo {
let repo = entry.repo.as_deref().unwrap_or("—");
spans.push(Span::styled(
format!("{:<width$} ", repo, width = cols.repo),
theme.accent(),
));
}
spans.push(Span::raw(format!(
"{:<width$} ",
entry.worktree.name,
width = cols.name
)));
spans.push(Span::styled(
format!("{:<width$} ", branch, width = cols.branch),
theme.muted(),
));
spans.push(Span::styled(format!("{dirty:>3} "), dirty_style));
if let Some(u) = &entry.upstream {
spans.push(Span::styled(format!("↑{} ", u.ahead), theme.ahead()));
spans.push(Span::styled(format!("↓{}", u.behind), theme.behind()));
}
Line::from(spans)
}
fn row(theme: &Theme, label: &str, value: impl Into<String>) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<12}"), theme.muted()),
Span::raw(value.into()),
])
}
fn help_row(theme: &Theme, keys: &str, desc: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {keys:<12} "), theme.accent()),
Span::raw(desc.to_string()),
])
}
fn key(theme: &Theme, s: &str) -> Span<'static> {
Span::styled(s.to_string(), theme.accent().add_modifier(Modifier::BOLD))
}