limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Ratatui render pass.
//!
//! Single entry point: [`draw`]. Everything else is file-local.

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;

/// Renders the picker for one frame.
///
/// Picks a 3-pane layout (repos | worktrees | preview) when the terminal
/// is at least `WIDE_BREAKPOINT` columns wide, single-pane otherwise.
/// Filter input and help overlays are drawn on top when active.
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))
}