bohay 0.8.2

Next-Gen Agents multiplexer
//! The folder-picker modal: choose a folder to open as a new workspace.
//! Browse the filesystem, pick an existing folder, or create a new one.

use super::*;
use crate::app::{FolderPicker, Row};
use crate::i18n::Catalog;
use ratatui::widgets::{Borders, Clear};

/// Draw the picker over a dimmed backdrop; returns the clickable row rects
/// (row index → rect) the input layer uses for mouse selection.
pub(super) fn draw_picker(
    f: &mut RenderTarget,
    area: Rect,
    p: &FolderPicker,
    cat: &Catalog,
    t: &Theme,
) -> Vec<(usize, Rect)> {
    dim_backdrop(f, area, t);

    let w = area.width.saturating_sub(6).clamp(46, 76).min(area.width);
    let h = area.height.saturating_sub(4).clamp(14, 26).min(area.height);
    let modal = centered_rect(area, w, h);
    f.render_widget(Clear, modal);
    let block = Block::new()
        .borders(Borders::ALL)
        .border_style(Style::new().fg(t.border_focus).bg(t.surface0))
        .style(Style::new().bg(t.surface0));
    let inner = block.inner(modal);
    f.render_widget(block, modal);

    // Title + the path being browsed.
    f.render_widget(
        Paragraph::new(Span::styled(
            format!(" {}", cat.open_workspace),
            Style::new().fg(t.text).bold(),
        )),
        Rect::new(inner.x, inner.y, inner.width, 1),
    );
    let path = p.path.display().to_string();
    let path = trunc_tail(&path, inner.width.saturating_sub(2) as usize);
    f.render_widget(
        Paragraph::new(Span::styled(format!(" {path}"), Style::new().fg(t.accent))),
        Rect::new(inner.x, inner.y + 1, inner.width, 1),
    );
    hline(f, inner.x, inner.y + 2, inner.width, t);

    // Footer: the new-folder input, an error, or the key hints.
    let footer_y = inner.bottom().saturating_sub(1);
    hline(f, inner.x, footer_y.saturating_sub(1), inner.width, t);
    if let Some(buf) = &p.creating {
        f.render_widget(
            Paragraph::new(Line::from(vec![
                Span::styled(
                    format!(" {}: ", cat.act_new_folder),
                    Style::new().fg(t.subtext0),
                ),
                Span::styled(buf.clone(), Style::new().fg(t.accent).bold()),
                Span::styled("", Style::new().fg(t.accent)),
            ])),
            Rect::new(inner.x, footer_y, inner.width, 1),
        );
    } else if let Some(e) = &p.error {
        f.render_widget(
            Paragraph::new(Span::styled(
                format!(" error: {e}"),
                Style::new().fg(t.coral),
            )),
            Rect::new(inner.x, footer_y, inner.width, 1),
        );
    } else {
        // Key hints: the shortcut in the theme accent, the label in light text —
        // over the modal's own background (no black bar). `⏎` acts on the
        // highlighted row (open folder / open with worktree / `..` / descend).
        f.render_widget(
            Paragraph::new(hint_line(
                &[
                    ("↑↓", cat.act_move),
                    ("", cat.act_select),
                    ("", cat.act_up),
                    ("n", cat.act_new_folder),
                    ("esc", cat.act_cancel),
                ],
                t,
            )),
            Rect::new(inner.x, footer_y, inner.width, 1),
        );
    }

    // The scrolling list: [Open this folder] · [..] · folders · files.
    let list = Rect::new(
        inner.x + 1,
        inner.y + 3,
        inner.width.saturating_sub(2),
        footer_y.saturating_sub(inner.y + 4),
    );
    let avail = list.height.max(1) as usize;
    let scroll = p.cursor.saturating_sub(avail.saturating_sub(1));
    let mut rects = Vec::new();
    for (vi, i) in (scroll..p.row_count()).take(avail).enumerate() {
        let y = list.y + vi as u16;
        let row_rect = Rect::new(list.x, y, list.width, 1);
        let sel = i == p.cursor;
        if sel {
            fill_bg(f, row_rect, t.sel_bg);
        }
        // (icon, label, color). Folders navigate; files are dimmed + inert.
        let (icon, label, fg) = match p.row(i) {
            Row::OpenFolder => ("", cat.open_this_folder.to_string(), t.accent),
            Row::OpenWorktree => ("", cat.open_with_worktree.to_string(), t.accent),
            Row::Up => ("", "..".to_string(), t.subtext0),
            Row::Entry(idx) => {
                let e = &p.entries[idx];
                if e.is_dir {
                    ("", format!("{}/", e.name), t.text)
                } else {
                    ("·", e.name.clone(), t.overlay0)
                }
            }
        };
        f.render_widget(
            Paragraph::new(Line::from(vec![
                Span::styled(if sel { "" } else { "  " }, Style::new().fg(t.accent)),
                Span::styled(format!("{icon} "), Style::new().fg(fg)),
                Span::styled(
                    trunc_tail(&label, list.width.saturating_sub(5) as usize),
                    Style::new().fg(fg),
                ),
            ])),
            Rect::new(list.x, y, list.width, 1),
        );
        rects.push((i, row_rect));
    }
    rects
}

/// Truncate a string to `max` columns, keeping the **tail** (the useful end of a
/// path) with a leading `…`.
fn trunc_tail(s: &str, max: usize) -> String {
    let n = s.chars().count();
    if n <= max || max == 0 {
        return s.to_string();
    }
    let tail: String = s.chars().skip(n - max.saturating_sub(1)).collect();
    format!("{tail}")
}

/// A tiny input modal: the new-worktree branch prompt (docs/18 WT). `error` is
/// shown in red (e.g. the branch is already checked out) so a failed create is
/// never a silent no-op.
pub(super) fn draw_worktree_prompt(
    f: &mut RenderTarget,
    area: Rect,
    buf: &str,
    error: Option<&str>,
    hover: Option<(u16, u16)>,
    cat: &Catalog,
    t: &Theme,
) -> (Option<Rect>, Option<Rect>) {
    dim_backdrop(f, area, t);
    let w = area.width.saturating_sub(6).clamp(36, 64).min(area.width);
    let modal = centered_rect(area, w, 6);
    f.render_widget(Clear, modal);
    let block = Block::new()
        .borders(Borders::ALL)
        .border_style(Style::new().fg(t.border_focus).bg(t.surface0))
        .style(Style::new().bg(t.surface0));
    let inner = block.inner(modal);
    f.render_widget(block, modal);
    f.render_widget(
        Paragraph::new(Span::styled(
            format!(" {}", cat.new_git_worktree),
            Style::new().fg(t.text).bold(),
        )),
        Rect::new(inner.x, inner.y, inner.width, 1),
    );
    f.render_widget(
        Paragraph::new(Line::from(vec![
            Span::styled(format!(" {}: ", cat.branch), Style::new().fg(t.subtext0)),
            Span::styled(buf.to_string(), Style::new().fg(t.accent).bold()),
            Span::styled("", Style::new().fg(t.accent)),
        ])),
        Rect::new(inner.x, inner.y + 2, inner.width, 1),
    );
    // Bottom line: the error (red) if the last create failed — never a silent
    // no-op — else the clickable key hints.
    let bottom = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
    if let Some(e) = error {
        let e = trunc_tail(e, inner.width.saturating_sub(2) as usize);
        f.render_widget(
            Paragraph::new(Span::styled(format!(" {e}"), Style::new().fg(t.coral))),
            bottom,
        );
        (None, None) // no hint buttons while the error occupies the line
    } else {
        let (c, x) = footer_hints(f, bottom, cat.act_create, cat.act_cancel, hover, t);
        (Some(c), Some(x))
    }
}

/// The tab-rename modal (docs/28): a single text field pre-filled with the tab's
/// current name. Mirrors `draw_worktree_prompt` (no error line).
pub(super) fn draw_tab_rename(
    f: &mut RenderTarget,
    area: Rect,
    buf: &str,
    hover: Option<(u16, u16)>,
    cat: &Catalog,
    t: &Theme,
) -> (Option<Rect>, Option<Rect>) {
    draw_rename(f, area, cat.rename_tab, buf, hover, cat, t)
}

/// The workspace-rename modal: titled for a node. The on-disk folder is never
/// touched; this edits the label only.
pub(super) fn draw_ws_rename(
    f: &mut RenderTarget,
    area: Rect,
    buf: &str,
    hover: Option<(u16, u16)>,
    cat: &Catalog,
    t: &Theme,
) -> (Option<Rect>, Option<Rect>) {
    draw_rename(f, area, cat.menu_rename, buf, hover, cat, t)
}

/// Shared single-field rename modal (tab / workspace): a title, an editable
/// buffer, and the clickable ⏎/esc footer hints. Returns each hint's rect.
fn draw_rename(
    f: &mut RenderTarget,
    area: Rect,
    title: &str,
    buf: &str,
    hover: Option<(u16, u16)>,
    cat: &Catalog,
    t: &Theme,
) -> (Option<Rect>, Option<Rect>) {
    dim_backdrop(f, area, t);
    let w = area.width.saturating_sub(6).clamp(36, 64).min(area.width);
    let modal = centered_rect(area, w, 6);
    f.render_widget(Clear, modal);
    let block = Block::new()
        .borders(Borders::ALL)
        .border_style(Style::new().fg(t.border_focus).bg(t.surface0))
        .style(Style::new().bg(t.surface0));
    let inner = block.inner(modal);
    f.render_widget(block, modal);
    f.render_widget(
        Paragraph::new(Span::styled(
            format!(" {title}"),
            Style::new().fg(t.text).bold(),
        )),
        Rect::new(inner.x, inner.y, inner.width, 1),
    );
    f.render_widget(
        Paragraph::new(Line::from(vec![
            Span::raw(" "),
            Span::styled(buf.to_string(), Style::new().fg(t.accent).bold()),
            Span::styled("", Style::new().fg(t.accent)),
        ])),
        Rect::new(inner.x, inner.y + 2, inner.width, 1),
    );
    let footer = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
    let (c, x) = footer_hints(f, footer, cat.act_save, cat.act_cancel, hover, t);
    (Some(c), Some(x))
}

/// Render the footer `⏎ commit · esc cancel` hints (the original left-aligned
/// look) and return each hint's clickable rect, so a click drives the same
/// commit / cancel as the key. The hint under the cursor gets a subtle highlight.
fn footer_hints(
    f: &mut RenderTarget,
    row: Rect,
    commit: &str,
    cancel: &str,
    hover: Option<(u16, u16)>,
    t: &Theme,
) -> (Rect, Rect) {
    // Each hint is padded by a space each side, so its hover pill is a little
    // wider than the text and reads as a proper button.
    let cw = super::display_width(&format!("{commit}")) as u16 + 2;
    let xw = super::display_width(&format!("esc {cancel}")) as u16 + 2;
    let commit_rect = Rect::new(row.x, row.y, cw.min(row.width), 1);
    let sep_x = row.x + cw; // the `·` sits between the two pills' padding
    let cancel_x = (sep_x + 1).min(row.right());
    let cancel_rect = Rect::new(
        cancel_x,
        row.y,
        xw.min(row.right().saturating_sub(cancel_x)),
        1,
    );
    let over = |r: Rect| hover.is_some_and(|(c, hr)| c >= r.x && c < r.right() && hr == r.y);
    draw_hint(f, commit_rect, "", commit, over(commit_rect), t);
    if sep_x < row.right() {
        f.render_widget(
            Paragraph::new(Span::styled("·", Style::new().fg(t.overlay0))),
            Rect::new(sep_x, row.y, 1, 1),
        );
    }
    draw_hint(f, cancel_rect, "esc", cancel, over(cancel_rect), t);
    (commit_rect, cancel_rect)
}

/// One footer hint ` ⏎ label `. When `hot`, the whole padded pill fills with the
/// theme accent (dark text on green); otherwise the key is the accent and the
/// label is light text, over the modal background (the original look).
fn draw_hint(f: &mut RenderTarget, rect: Rect, key: &str, label: &str, hot: bool, t: &Theme) {
    if hot {
        fill_bg(f, rect, t.accent);
    }
    let (kfg, lfg) = if hot {
        (t.crust, t.crust)
    } else {
        (t.accent, t.subtext1)
    };
    f.render_widget(
        Paragraph::new(Line::from(vec![
            Span::raw(" "),
            Span::styled(key.to_string(), Style::new().fg(kfg).bold()),
            Span::styled(format!(" {label} "), Style::new().fg(lfg)),
        ])),
        rect,
    );
}

// ── local render helpers (each modal module keeps its own, as elsewhere) ──

fn centered_rect(area: Rect, w: u16, h: u16) -> Rect {
    let w = w.min(area.width);
    let h = h.min(area.height);
    Rect::new(
        area.x + (area.width - w) / 2,
        area.y + (area.height - h) / 2,
        w,
        h,
    )
}

/// Dim the whole frame toward `crust` so the dialog reads as focused.
fn dim_backdrop(f: &mut RenderTarget, area: Rect, t: &Theme) {
    let buf = f.buffer_mut();
    for y in area.top()..area.bottom() {
        for x in area.left()..area.right() {
            let cell = &mut buf[(x, y)];
            cell.set_fg(t.overlay0);
            cell.set_bg(t.crust);
        }
    }
}

fn hline(f: &mut RenderTarget, x: u16, y: u16, w: u16, t: &Theme) {
    let buf = f.buffer_mut();
    for i in 0..w {
        buf[(x + i, y)]
            .set_symbol("")
            .set_style(Style::new().fg(t.surface1).bg(t.surface0));
    }
}

fn fill_bg(f: &mut RenderTarget, rect: Rect, color: Color) {
    let buf = f.buffer_mut();
    for y in rect.y..rect.bottom() {
        for x in rect.x..rect.right() {
            buf[(x, y)].set_bg(color);
        }
    }
}