bohay 0.9.6

Mission control for your AI coding agents
//! Pane content: the terminal grid blit, the lone-pane header bar, and the
//! dot+path+close title drawn onto each split pane's top border.

use super::*;

/// Draw the dot + path (+ ✕ for the focused pane) as a title ON each pane's top
/// border row, after the borders are drawn, so it lands on the tab bar edge.
pub(super) fn draw_pane_titles(
    f: &mut RenderTarget,
    rects: &[(PaneId, Rect)],
    focus: PaneId,
    app: &App,
    t: &Theme,
) -> Vec<(PaneId, Rect)> {
    let mut title_rects = Vec::new();
    for (id, rect) in rects {
        if rect.width < 8 || rect.height < 2 {
            continue;
        }
        // A view leaf's title is its file path + a state dot placeholder.
        if let Some(crate::app::ViewKind::File(v)) = app.views.get(id) {
            let focused = *id == focus;
            let bg = t.mantle;
            let inner_w = rect.width - 2;
            let btn_w = title_buttons_w(focused, rect.width);
            let title_w = inner_w.saturating_sub(btn_w);
            let name = v
                .path
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_default();
            let path_fg = if focused { t.accent } else { t.subtext0 };
            // A plain small-square glyph (no emoji), neutral marker.
            let dot = Span::styled("", Style::new().fg(t.overlay1).bg(bg));
            let label: String = name
                .chars()
                .take(title_w.saturating_sub(3) as usize)
                .collect();
            let text_w = (3 + label.chars().count() as u16).min(title_w);
            let title_rect = Rect::new(rect.x + 1, rect.y, text_w, 1);
            f.render_widget(
                Paragraph::new(Line::from(vec![
                    dot,
                    Span::styled(label, Style::new().fg(path_fg).bg(bg)),
                ])),
                title_rect,
            );
            title_rects.push((*id, title_rect));
            draw_title_buttons(f, *rect, focused, app.zoomed, title_w, bg, t);
            continue;
        }
        let Some(pane) = app.panes.get(id) else {
            continue;
        };
        let focused = *id == focus;
        let st = pane_state(app, *id);
        let path_fg = if focused { t.accent } else { t.subtext0 };
        // The top border is a thin rule now (not a filled bar), so the title
        // sits on the dark background; only the text cells are painted, leaving
        // the thin `▔` line visible on either side of the label.
        let bg = t.mantle;
        let inner_w = rect.width - 2; // inside the two corner cells
        let btn_w = title_buttons_w(focused, rect.width);
        let title_w = inner_w.saturating_sub(btn_w);
        let path = short_path(&pane.cwd, title_w.saturating_sub(4));
        let text_w = (3 + path.chars().count() as u16).min(title_w);
        let title = Line::from(vec![
            Span::styled(
                format!(" {} ", st.dot()),
                Style::new().fg(st.color(t)).bg(bg),
            ),
            Span::styled(path, Style::new().fg(path_fg).bg(bg)),
        ]);
        let title_rect = Rect::new(rect.x + 1, rect.y, text_w, 1);
        f.render_widget(Paragraph::new(title), title_rect);
        // Clicking the title opens the running-command overlay.
        title_rects.push((*id, title_rect));
        draw_title_buttons(f, *rect, focused, app.zoomed, title_w, bg, t);
    }
    title_rects
}

/// Cells reserved on the right of a focused pane's title for its buttons: the ✕,
/// plus the ⤢ zoom toggle when the pane is wide enough for both. Must match
/// `pane_close_rect`/`pane_zoom_rect` in `ui/mod.rs`, or a tap lands off the
/// glyph.
fn title_buttons_w(focused: bool, width: u16) -> u16 {
    if !focused {
        0
    } else if width >= 12 {
        6
    } else {
        3
    }
}

/// Draw the focused pane's title buttons at the right edge: ⤢/⤡ (zoom/restore)
/// then ✕, each a 3-cell hit target aligned with the rects `ui/mod.rs` records.
fn draw_title_buttons(
    f: &mut RenderTarget,
    rect: Rect,
    focused: bool,
    zoomed: bool,
    title_w: u16,
    bg: Color,
    t: &Theme,
) {
    if !focused {
        return;
    }
    let style = Style::new().fg(t.subtext1).bg(bg).bold();
    let bx = rect.x + 1 + title_w;
    let close = |f: &mut RenderTarget, x: u16| {
        f.render_widget(
            Paragraph::new(Span::styled(" × ", style)),
            Rect::new(x, rect.y, 3, 1),
        );
    };
    if rect.width >= 12 {
        // ⤢ expands a split to fullscreen; ⤡ restores it (touch-reachable zoom).
        let zoom = if zoomed { "" } else { "" };
        f.render_widget(
            Paragraph::new(Span::styled(zoom, style)),
            Rect::new(bx, rect.y, 3, 1),
        );
        close(f, bx + 3);
    } else {
        close(f, bx);
    }
}

// ── panes ─────────────────────────────────────────────────────────────────

pub(super) fn draw_panes(
    f: &mut RenderTarget,
    rects: &[(PaneId, Rect)],
    bordered: bool,
    app: &App,
    t: &Theme,
) -> Option<(u16, u16)> {
    let focus = app.layout().focus;
    let mut cursor = None;
    for (id, rect) in rects {
        if let Some(c) = draw_one_pane(f, *rect, *id, *id == focus, bordered, app, t) {
            cursor = Some(c);
        }
    }
    cursor
}

fn draw_one_pane(
    f: &mut RenderTarget,
    area: Rect,
    id: PaneId,
    focused: bool,
    bordered: bool,
    app: &App,
    t: &Theme,
) -> Option<(u16, u16)> {
    // A view leaf (docs/38 FILE-3) renders natively, not from a PTY.
    if let Some(crate::app::ViewKind::File(v)) = app.views.get(&id) {
        let content = pane_content(area, bordered)?;
        let sel = app.selection.filter(|s| s.pane == id);
        super::files::draw_file_view(f, content, v, sel.as_ref(), t);
        return None; // views own no terminal cursor
    }
    let pane = app.panes.get(&id)?;
    let st = pane_state(app, id);
    let content = pane_content(area, bordered)?;

    // A lone pane has no border, so it shows a header bar on its top row.
    // Bordered panes instead get their dot+path+close as a title ON the top
    // border row (see `draw_pane_titles`), so it touches the tab bar.
    if !bordered {
        // Match the content's horizontal pad so the header bar aligns with the
        // tab bar and the terminal text below it.
        let pad = lone_pad(area.width);
        let header = Rect::new(area.x + pad, area.y, area.width.saturating_sub(2 * pad), 1);
        let hbg = if focused { t.surface1 } else { t.surface0 };
        let path_fg = if focused { t.accent } else { t.overlay1 };
        f.render_widget(Block::new().style(Style::new().bg(hbg)), header);
        // When this lone pane is a *zoomed* split (not just the only pane), show a
        // ⤡ restore button so a phone can un-zoom without a keyboard (docs/18).
        let show_restore = app.zoomed && header.width >= 8;
        let path_budget = header
            .width
            .saturating_sub(if show_restore { 8 } else { 5 });
        let title = Line::from(vec![
            Span::styled("", Style::new().fg(t.accent).bg(hbg)),
            Span::styled(
                format!(" {} ", st.dot()),
                Style::new().fg(st.color(t)).bg(hbg),
            ),
            Span::styled(
                short_path(&pane.cwd, path_budget),
                Style::new().fg(path_fg).bg(hbg),
            ),
        ]);
        f.render_widget(Paragraph::new(title), header);
        if show_restore {
            let r = super::lone_zoom_rect(area);
            f.render_widget(
                Paragraph::new(Span::styled(
                    "",
                    Style::new().fg(t.subtext1).bg(hbg).bold(),
                )),
                r,
            );
        }
    }

    // Content background = the dark pane background.
    f.render_widget(Block::new().style(Style::new().bg(t.mantle)), content);

    let downsample = app.downsample;
    // A mouse text-selection in this pane highlights its cells.
    let sel = app.selection.filter(|s| s.pane == id);
    let mut scrolled = 0usize;
    let cursor_pos = match pane.engine.lock() {
        Ok(engine) => {
            {
                let buf = f.buffer_mut();
                engine.for_each_cell(&mut |row, col, sym, cell| {
                    if row >= content.height || col >= content.width {
                        return;
                    }
                    let x = content.x + col;
                    let y = content.y + row;
                    let conv = |c: Color| {
                        if downsample {
                            crate::ipc::protocol::to_256(c)
                        } else {
                            c
                        }
                    };
                    let fg = if cell.fg == Color::Reset {
                        t.text
                    } else {
                        conv(cell.fg)
                    };
                    let mut style = Style::new().fg(fg);
                    if !cell.mods.is_empty() {
                        style = style.add_modifier(cell.mods);
                    }
                    if cell.bg != Color::Reset {
                        style = style.bg(conv(cell.bg));
                    }
                    // Highlight the cell if it's inside the mouse selection.
                    if sel.is_some_and(|s| s.contains(x, y)) {
                        style = style.bg(t.sel_bg);
                    }
                    // ratatui panics if a control char reaches the buffer; the VT
                    // grid can hold stray C0/C1 bytes, so render those blank. `sym`
                    // is the whole grapheme cluster (base + combining/VS16/ZWJ), so
                    // emoji and accents print whole instead of losing their modifier
                    // chars to a tofu box.
                    let sym = if sym.starts_with(|c: char| c.is_control()) {
                        " "
                    } else {
                        sym
                    };
                    let target = &mut buf[(x, y)];
                    target.set_symbol(sym);
                    target.set_style(style);
                    // A double-width glyph (emoji / CJK) spans this cell *and* the
                    // next. The VT engine skips the spacer, so that next cell keeps
                    // the reset blank — which the client would blit as a space over
                    // the glyph's right half (crossterm advances its cursor +1 per
                    // cell, but the terminal advances +2 for the glyph), corrupting
                    // it and shifting the row. Mark it as a wide-char continuation:
                    // an empty symbol prints nothing, realigning the accounting.
                    if x + 1 < content.x + content.width
                        && unicode_width::UnicodeWidthStr::width(sym) == 2
                    {
                        let next = &mut buf[(x + 1, y)];
                        next.set_symbol("");
                        next.set_style(style); // carry bg so a highlight stays contiguous
                    }
                });
            }
            scrolled = engine.scroll_offset();
            let cur = engine.cursor();
            if focused && cur.visible && cur.x < content.width && cur.y < content.height {
                Some((content.x + cur.x, content.y + cur.y))
            } else {
                None
            }
        }
        Err(_) => None,
    };

    // Scrollback indicator: when the viewport is above the live bottom, show how
    // far up (in lines) at the content's top-right so the state is never a
    // mystery. Any keystroke — or scrolling back down — returns to live.
    if scrolled > 0 && content.height > 0 {
        let label = format!("{scrolled} ");
        let w = crate::ui::display_width(&label) as u16;
        if w < content.width {
            let badge = Rect::new(content.x + content.width - w, content.y, w, 1);
            f.render_widget(
                Paragraph::new(Line::from(Span::styled(
                    label,
                    Style::new().fg(t.crust).bg(t.accent),
                ))),
                badge,
            );
        }
    }
    cursor_pos
}