//! Per-frame render functions.
//!
//! [`frame`] is the top-level entry point called from the event loop.
//! It splits the terminal area into a buffer pane + status line row and
//! delegates to [`buffer_pane`] and [`status_line`].

use std::borrow::Cow;

use hjkl_buffer::{FoldIndex, Viewport};
use hjkl_buffer_tui::{BufferView, DiagOverlay, Gutter, GutterNumbers};
use hjkl_engine::{Host, Query};
use hjkl_statusline::{
    Bar, Color as SlColor, Segment as SlSegment, StatusTheme, Style as SlStyle, StyleExt,
    dirty_segment, filename_segment, loading_segment, mode_segment, pending_segment,
    search_count_segment, truncate_filename,
};
use hjkl_vim::VimEditorExt;
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};

use crate::app::{App, DiagSeverity, DiskState, STATUS_LINE_HEIGHT, TOP_BAR_HEIGHT, window};
use hjkl_holler_tui::{HollerLayout, render_active};
use hjkl_prompt_tui::{PromptTheme, build_prompt_line};
use hjkl_tabs::TabBar;
use hjkl_tabs_tui::{TabBarTheme, build_line as build_tab_line};

// ── Explorer color constants ──────────────────────────────────────────────────

/// Git-status BACKGROUND colors for explorer filenames. The status is shown as
/// the name's background so the filetype/dir foreground color stays distinct
/// from the git state. The name text itself is repainted [`GIT_TEXT`] for
/// contrast against these mid-tone backgrounds.
const GIT_MODIFIED: Color = Color::Rgb(0xd7, 0x87, 0x5f);
const GIT_STAGED: Color = Color::Rgb(0x87, 0xaf, 0x5f);
const GIT_DELETED: Color = Color::Rgb(0xd7, 0x5f, 0x5f);
const GIT_UNTRACKED: Color = Color::Rgb(0x5f, 0xaf, 0xaf);

/// High-contrast text color painted over a git-status background so the
/// filename stays readable on any of the mid-tone status backgrounds.
const GIT_TEXT: Color = Color::Rgb(0x1c, 0x1c, 0x1c);

/// Convert an `Option<(u8,u8,u8)>` RGB triple to a ratatui `Color::Rgb`.
fn rgb(o: Option<(u8, u8, u8)>) -> Option<Color> {
    o.map(|(r, g, b)| Color::Rgb(r, g, b))
}

/// Convert a `ratatui::style::Color::Rgb` to `hjkl_statusline::Color`.
/// Named/indexed colors fall back to white.
fn ratatui_rgb_to_sl(c: Color) -> SlColor {
    match c {
        Color::Rgb(r, g, b) => SlColor::rgb(r, g, b),
        _ => SlColor::rgb(0xff, 0xff, 0xff),
    }
}

/// Convert a `ratatui::style::Color::Rgb` to `hjkl_theme::Color`.
/// Named/indexed colors fall back to white.
fn to_hjkl_color(c: Color) -> hjkl_theme::Color {
    match c {
        Color::Rgb(r, g, b) => hjkl_theme::Color::rgb(r, g, b),
        _ => hjkl_theme::Color::rgb(0xff, 0xff, 0xff),
    }
}

/// Build a `StatusTheme` from the app's `UiTheme`.
///
/// `StatusTheme` is `#[non_exhaustive]`; construct via `Default` + field mutation
/// so new colour slots added upstream don't break this site.
fn app_status_theme(app: &App) -> StatusTheme {
    let ui = &app.theme.ui;
    let mut t = StatusTheme::default();
    t.bg = ratatui_rgb_to_sl(ui.surface_bg);
    t.fg = ratatui_rgb_to_sl(ui.text);
    t.fill_bg = ratatui_rgb_to_sl(ui.panel_bg);
    t.mode_normal_bg = ratatui_rgb_to_sl(ui.mode_normal_bg);
    t.mode_normal_fg = ratatui_rgb_to_sl(ui.on_accent);
    t.mode_insert_bg = ratatui_rgb_to_sl(ui.mode_insert_bg);
    t.mode_insert_fg = ratatui_rgb_to_sl(ui.on_accent);
    t.mode_visual_bg = ratatui_rgb_to_sl(ui.mode_visual_bg);
    t.mode_visual_fg = ratatui_rgb_to_sl(ui.on_accent);
    t.dirty_fg = ratatui_rgb_to_sl(ui.status_dirty_marker);
    t.readonly_fg = ratatui_rgb_to_sl(ui.text);
    t.new_file_fg = ratatui_rgb_to_sl(ui.text);
    t.recording_bg = ratatui_rgb_to_sl(ui.recording_bg);
    t.recording_fg = ratatui_rgb_to_sl(ui.recording_fg);
    t
}

/// Build the normal-mode status bar as an agnostic `Bar`.
///
/// Populates left/right segments from app state. The caller converts
/// to ratatui via `hjkl_statusline_tui::to_line`.
pub fn build_normal_status_bar(app: &App, width: u16) -> Line<'static> {
    let theme = app_status_theme(app);
    let ui = &app.theme.ui;
    let mode = app.mode_label();

    let mut bar = Bar {
        fill_style: SlStyle::default_style()
            .bg(ratatui_rgb_to_sl(ui.panel_bg))
            .fg(ratatui_rgb_to_sl(ui.text)),
        ..Default::default()
    };

    // ── Left side ────────────────────────────────────────────────────────────
    // Note: recording @r is handled in build_status_line as a full-line
    // takeover (vim bottom-line behaviour). It never reaches this path.
    bar.left.push(mode_segment(mode, &theme));

    {
        let pc = app.active_editor().pending_count();
        let po = app.active_editor().pending_op();
        let po_str = po.map(|s| s.to_string());
        if let Some(seg) = pending_segment(pc.map(|n| n as u64), po_str.as_deref(), &theme) {
            bar.left.push(seg);
        }
    }

    // Filename with tags.
    let ro_tag = if app.active_editor().is_readonly() {
        " [RO]"
    } else {
        ""
    };
    let new_tag = if app.active().is_new_file {
        " [New File]"
    } else {
        ""
    };
    let disk_tag = match app.active().disk_state {
        DiskState::DeletedOnDisk => " [deleted]",
        DiskState::ChangedOnDisk => " [changed on disk]",
        DiskState::Synced => "",
    };
    let untracked_tag = if app.active().is_untracked && !app.active().is_new_file {
        " [Untracked]"
    } else {
        ""
    };
    let raw_filename: Cow<str> = app
        .active()
        .filename
        .as_ref()
        .and_then(|p| p.to_str())
        .map_or(Cow::Borrowed("[No Name]"), Cow::Borrowed);
    let suffix = format!("{ro_tag}{new_tag}{disk_tag}{untracked_tag}");

    // Reserve space for all right-side blocks + filename padding + suffix
    // to determine how many chars the filename itself can occupy.
    // We use char counts to stay consistent with Bar::layout.
    let (row, col) = app.active_editor().cursor();
    let line_count = app.active_editor().buffer().line_count() as usize;

    let pos_content = format!(" {}:{} ", row + 1, col + 1);
    let pct_content = {
        let pct = ((row + 1) * 100).checked_div(line_count).unwrap_or(0);
        format!(" {pct}% ")
    };

    // Build loading label (used both for reservation + actual segment).
    let loading_label: String = if !app.lsp_pending.is_empty() {
        let label = app
            .lsp_pending
            .values()
            .next()
            .map_or("request", |p| match p {
                crate::app::LspPendingRequest::GotoDefinition { .. } => "definition",
                crate::app::LspPendingRequest::GotoDeclaration { .. } => "declaration",
                crate::app::LspPendingRequest::GotoTypeDefinition { .. } => "type definition",
                crate::app::LspPendingRequest::GotoImplementation { .. } => "implementation",
                crate::app::LspPendingRequest::GotoReferences { .. } => "references",
                crate::app::LspPendingRequest::Hover { .. } => "hover",
                crate::app::LspPendingRequest::Completion { .. } => "completion",
                crate::app::LspPendingRequest::CodeAction { .. } => "code action",
                crate::app::LspPendingRequest::Rename { .. } => "rename",
                _ => "request",
            });
        format!("{} LSP:{label}", hjkl_editor_tui::spinner::frame())
    } else {
        let names = app.directory.in_flight_names();
        match names.len() {
            0 => String::new(),
            1 => format!("{} grammar:{}", hjkl_editor_tui::spinner::frame(), names[0]),
            n => format!(
                "{} grammar:{} +{}",
                hjkl_editor_tui::spinner::frame(),
                names[0],
                n - 1
            ),
        }
    };

    // Diag count content + segment color, computed in ONE pass over the
    // active slot's diagnostics. The two used to scan the whole list
    // separately — the tally here, and severity-presence `.any()` checks in
    // the segment builder below. One scan per frame instead of two.
    let (diag_count_content, diag_fg): (String, SlColor) = {
        let diags = &app.active().lsp_diags;
        if diags.is_empty() {
            (String::new(), theme.diag_hint_fg)
        } else {
            let (mut e, mut w2, mut i, mut h) = (0usize, 0usize, 0usize, 0usize);
            for d in diags {
                match d.severity {
                    DiagSeverity::Error => e += 1,
                    DiagSeverity::Warning => w2 += 1,
                    DiagSeverity::Info => i += 1,
                    DiagSeverity::Hint => h += 1,
                }
            }
            let mut parts = Vec::new();
            if e > 0 {
                parts.push(format!("E:{e}"));
            }
            if w2 > 0 {
                parts.push(format!("W:{w2}"));
            }
            if i > 0 {
                parts.push(format!("I:{i}"));
            }
            if h > 0 {
                parts.push(format!("H:{h}"));
            }
            let content = if parts.is_empty() {
                String::new()
            } else {
                format!(" {} ", parts.join(" "))
            };
            // Highest severity present wins the color, mirroring the old
            // `.any()` presence checks (counts are equivalent: each diag has
            // exactly one severity).
            let fg = if e > 0 {
                theme.diag_error_fg
            } else if w2 > 0 {
                theme.diag_warning_fg
            } else if i > 0 {
                theme.diag_info_fg
            } else {
                theme.diag_hint_fg
            };
            (content, fg)
        }
    };

    // Search count content.
    let search_count_content: String = search_count(app)
        .map(|(idx, total)| format!(" [{idx}/{total}] "))
        .unwrap_or_default();

    // Dirty marker content.
    let dirty_content = if app.active().dirty { " ● " } else { "" };

    // Compute available chars for filename:
    // total = left_fixed + " " + name + suffix + " " + dirty + search + diag + loading + pos + pct
    // Note: rec_chars omitted — recording is a full-line takeover handled in
    // build_status_line and never reaches this function.
    let w = width as usize;
    let mode_chars = mode.chars().count() + 2; // " MODE "
    let pending_chars = {
        let pc = app.active_editor().pending_count();
        let po = app.active_editor().pending_op();
        match (pc, po) {
            (Some(n), Some(op)) => format!(" {n}{op} ").chars().count(),
            (Some(n), None) => format!(" {n} ").chars().count(),
            (None, Some(op)) => format!(" {op} ").chars().count(),
            (None, None) => 0,
        }
    };
    let right_chars = pos_content.chars().count() + pct_content.chars().count();
    let loading_chars = if loading_label.is_empty() {
        0
    } else {
        loading_label.chars().count() + 2 // " ... "
    };
    let reserved = mode_chars
        + pending_chars
        + 2 // " name "
        + suffix.chars().count()
        + dirty_content.chars().count()
        + search_count_content.chars().count()
        + diag_count_content.chars().count()
        + loading_chars
        + right_chars;
    let avail_for_name = w.saturating_sub(reserved);
    let filename = truncate_filename(&raw_filename, avail_for_name);

    bar.left.push(filename_segment(&filename, &suffix, &theme));

    // ── Left-side extras (dirty, search, diag, loading) go as left segments ─
    if let Some(seg) = dirty_segment(app.active().dirty, &theme) {
        bar.left.push(seg);
    }

    if !search_count_content.is_empty() {
        bar.left.push(search_count_segment(
            0, // dummy — we use raw content via loading_segment
            0, &theme,
        ));
        // Replace last segment with the actual pre-formatted content.
        if let Some(SlSegment::Text { content, .. }) = bar.left.last_mut() {
            *content = search_count_content.into();
        }
    }

    if !diag_count_content.is_empty() {
        // Color by highest severity — routed through StatusTheme so the host
        // controls the palette (adapts to terminal-named colors vs RGB).
        // `diag_fg` was computed in the same pass as `diag_count_content`
        // above, so the segment builder does not re-scan the diagnostics.
        bar.left.push(SlSegment::Text {
            content: diag_count_content.into(),
            style: SlStyle::default_style()
                .bg(ratatui_rgb_to_sl(ui.surface_bg))
                .fg(diag_fg),
        });
    }

    if !loading_label.is_empty() {
        bar.left.push(loading_segment(&loading_label, "", &theme));
        // Fix content: loading_segment adds " frame label " but we want " frame+label "
        if let Some(SlSegment::Text { content, .. }) = bar.left.last_mut() {
            *content = format!(" {loading_label} ").into();
        }
    }

    // ── Right side ───────────────────────────────────────────────────────────
    bar.right.push(SlSegment::Text {
        content: pos_content.into(),
        style: SlStyle::default_style()
            .bg(ratatui_rgb_to_sl(ui.surface_bg))
            .fg(ratatui_rgb_to_sl(ui.text)),
    });
    bar.right.push(SlSegment::Text {
        content: pct_content.into(),
        style: SlStyle::default_style()
            .bg(ratatui_rgb_to_sl(ui.mode_normal_bg))
            .fg(ratatui_rgb_to_sl(ui.on_accent))
            .bold(),
    });

    hjkl_statusline_tui::to_line(&bar, width)
}

/// Build the style for a diagnostic severity used in overlays and the status line.
/// Style for the diagnostic span overlay on the offending code. Only the
/// *underline* is colored (by severity) — the text keeps its syntax-highlight
/// foreground, so the line is not recolored, just underlined. Applied via
/// `Style::patch`, so leaving `fg` unset preserves the cell's existing color.
fn diag_severity_style(sev: DiagSeverity) -> Style {
    Style::default()
        .underline_color(diag_severity_fg(sev))
        .add_modifier(Modifier::UNDERLINED)
}

/// Severity color: red = error, orange = warning, green = information,
/// cyan = hint. Used for the colored underline on the offending span and for
/// the inline end-of-line ghost-text hint, so both read the same palette.
fn diag_severity_fg(sev: DiagSeverity) -> Color {
    match sev {
        DiagSeverity::Error => Color::Red,
        DiagSeverity::Warning => Color::Rgb(255, 165, 0), // orange
        DiagSeverity::Info => Color::Green,
        DiagSeverity::Hint => Color::Cyan,
    }
}

/// Sort rank for severity — lower is more severe. Used to pick the single
/// diagnostic to surface as the cursor-line inline hint.
fn diag_severity_rank(sev: DiagSeverity) -> u8 {
    match sev {
        DiagSeverity::Error => 0,
        DiagSeverity::Warning => 1,
        DiagSeverity::Info => 2,
        DiagSeverity::Hint => 3,
    }
}

/// Char-safe truncation with an ellipsis. Byte-indexing `&str` panics when the
/// cut lands inside a multi-byte char (CJK / emoji); counting chars avoids it.
fn truncate_chars(s: &str, max: usize) -> String {
    if s.chars().count() > max {
        format!("{}\u{2026}", s.chars().take(max).collect::<String>())
    } else {
        s.to_string()
    }
}

/// `true` when `row` falls inside the visible viewport `[vp_top, vp_bot)`.
///
/// Shared by the per-window `visible_diags` precompute and the eol-hint
/// builder in `render_window` so both skip diagnostics outside the current
/// viewport instead of touching every entry of the uncapped
/// `slot.lsp_diags` list every frame (audit D2 — allocation churn
/// proportional to total diagnostic count, independent of what's on screen).
fn row_in_viewport(row: usize, vp_top: usize, vp_bot: usize) -> bool {
    row >= vp_top && row < vp_bot
}

/// Build `DiagOverlay` items from a viewport-filtered diagnostic slice.
/// `render_window` filters `slot.lsp_diags` to the visible viewport ONCE
/// per frame (`visible_diags`) and feeds the SAME slice to this pass and
/// the eol-hint builder, so neither re-scans the uncapped list (the three
/// consumers used to filter independently — three scans per window per
/// frame, each with a fresh `Vec`).
fn build_diag_overlays(
    diags: &[&crate::app::LspDiag],
    _ui: &crate::theme::UiTheme,
) -> Vec<DiagOverlay> {
    diags
        .iter()
        .map(|d| DiagOverlay {
            row: d.start_row,
            col_start: d.start_col,
            col_end: if d.end_row == d.start_row && d.end_col > d.start_col {
                d.end_col
            } else {
                d.start_col + 1
            },
            style: diag_severity_style(d.severity),
        })
        .collect()
}

/// Widest line-number gutter across all open (non-explorer) buffers, so the
/// number column can be sized to the biggest file and the text column stays put
/// when switching buffers. Buffers with line numbers off contribute 0.
///
/// Deliberately `is_explorer`, NOT `is_special` (#63 Phase 5 audit): this set
/// must be exactly the set of windows that RENDER a gutter, and the explorer
/// is the only gutterless one — the quickfix dock and the `q:` window draw
/// their gutters like any other window (vim's quickfix window honours
/// `'number'` too). Excluding them from the max while `render_window` still
/// sizes their number column to it would make their own text column narrower
/// than the numbers they draw. [`stable_gutter_extra`] and
/// [`rendered_gutter_width`] partition on the same line for the same reason;
/// all three move together or not at all.
pub fn max_lnum_width(app: &App) -> u16 {
    app.slots()
        .iter()
        .filter(|s| !s.is_explorer())
        .map(|s| s.lnum_width())
        .max()
        .unwrap_or(0)
}

/// Stable `(sign_column_width, fold_column_width)` reserved for ALL non-explorer
/// buffers — sized to the max each would need across every open buffer. This
/// keeps the text column from shifting when a diagnostic/git sign (or a fold)
/// appears in one buffer but not another (or scrolls in/out of view): once any
/// buffer needs a sign/fold column, it's reserved everywhere.
///
/// The sign decision uses whether a buffer has ANY signs (not just ones in the
/// current viewport), so scrolling within a buffer doesn't jiggle either.
///
/// `is_explorer` rather than `is_special` on purpose — see
/// [`max_lnum_width`]: the explorer is the only gutterless window.
pub fn stable_gutter_extra(app: &App) -> (u16, u16) {
    use hjkl_engine::types::SignColumnMode;
    let mut sign_w = 0u16;
    let mut fold_w = 0u16;
    for slot in app.slots().iter().filter(|s| !s.is_explorer()) {
        let st = slot.settings();
        let has_any_signs = !slot.diag_signs.is_empty()
            || !slot.diag_signs_lsp.is_empty()
            || !slot.git_signs.is_empty();
        let sw = match st.signcolumn {
            SignColumnMode::Yes => 1,
            SignColumnMode::No => 0,
            SignColumnMode::Auto => {
                if has_any_signs {
                    1
                } else {
                    0
                }
            }
        };
        sign_w = sign_w.max(sw);
        let has_folds = slot.buffer().has_folds();
        let fw = (st.foldcolumn.min(12) as u16).max(if has_folds { 1 } else { 0 });
        fold_w = fold_w.max(fw);
    }
    // Window-level folds: the shared buffer only holds the focused window's
    // set, so an unfocused window with folds wouldn't be seen by the loop
    // above. Reserve the fold column if ANY window's snapshot has folds.
    if fold_w == 0 && app.window_folds.values().any(|f| !f.is_empty()) {
        fold_w = 1;
    }
    (sign_w, fold_w)
}

/// Total rendered gutter width (sign + number + fold cells) for `slot_idx`,
/// matching exactly what `render_window` draws: the number column is sized to
/// the cross-buffer max, and the sign/fold columns are the stable reserved
/// widths. The explorer pane is gutterless (0) — and only the explorer, which
/// is what `render_window` itself draws (`is_explorer_slot` there); this is
/// the single source of truth shared with the mouse hit-test so clicks map to
/// the right column even when the gutter is widened beyond the buffer's own
/// line-count width, so the two must agree exactly.
pub fn rendered_gutter_width(app: &App, win_id: window::WindowId) -> u16 {
    let Some(Some(win)) = app.windows.get(win_id) else {
        return 0;
    };
    let slot_idx = win.slot;
    let Some(slot) = app.slots().get(slot_idx) else {
        return 0;
    };
    if slot.is_explorer() {
        return 0;
    }
    let (sign_w, fold_w) = stable_gutter_extra(app);
    // lnum_width depends on per-window `number`/`relativenumber` (#151 Phase D) —
    // read this window's editor so the mouse gutter matches render_window's.
    let own_lnum = app
        .window_editors
        .get(&win_id)
        .map_or_else(|| slot.lnum_width(), |e| e.lnum_width());
    let num_gw = if own_lnum > 0 { max_lnum_width(app) } else { 0 };
    sign_w + num_gw + fold_w
}

/// Parse a comma-separated colorcolumn string into a sorted `Vec<u16>` of
/// 1-based column indices. Non-numeric or zero entries are silently ignored.
fn parse_colorcolumn(cc: &str) -> Vec<u16> {
    if cc.is_empty() {
        return Vec::new();
    }
    let mut cols: Vec<u16> = cc
        .split(',')
        .filter_map(|s| s.trim().parse::<u16>().ok())
        .filter(|&n| n > 0)
        .collect();
    cols.sort_unstable();
    cols.dedup();
    cols
}

/// Bg painted across the cursor row in both the editor pane and the
/// picker preview pane. Subtle blue-grey — visible enough to track the
/// cursor at a glance without competing with the syntax foreground.
fn cursor_line_bg(theme: &crate::theme::UiTheme) -> Style {
    Style::default().bg(theme.cursor_line_bg)
}

/// Base background painted behind the editor text area + gutter (#303).
///
/// Returns `Style::default()` (no fill — the terminal's own background shows
/// through, vim's transparent behaviour) when `app.theme_transparent` is set;
/// otherwise a `bg`-only style using the theme's editor background. Threaded
/// into [`hjkl_buffer_tui::BufferView::background`], which layers it under
/// syntax / cursorline / selection styling.
/// Base background for the whole terminal, not just the editor pane.
///
/// Same rule as [`buffer_background_style`] — `Style::default()` (transparent)
/// under `theme.transparent`, else the theme's editor background — but applied
/// frame-wide so chrome that paints no background of its own (the splash
/// screen, gaps beside the tabline and explorer rows, separator cells) shows
/// the theme instead of the terminal's own colour.
pub fn frame_background_style(app: &App) -> Style {
    buffer_background_style(app)
}

pub fn buffer_background_style(app: &App) -> Style {
    if app.theme_transparent {
        Style::default()
    } else {
        Style::default().bg(app.theme.ui.background)
    }
}

/// Draw a 1-cell-wide separator between sibling panes.
///
/// For `SplitDir::Vertical` (side-by-side panes) the separator is a column
/// of `│` characters. For `SplitDir::Horizontal` (stacked panes) it is a
/// row of `─` characters. The separator uses `theme.border` so it matches
/// the popup / picker border color without requiring a new theme field.
fn draw_separator(
    frame: &mut Frame,
    sep_rect: Rect,
    dir: window::SplitDir,
    border_color: ratatui::style::Color,
    background: Style,
) {
    use ratatui::buffer::Cell;
    // The cell is reset before the glyph goes down (so no window content bleeds
    // through a wide character), which also drops the frame's base background —
    // hence painting it back here rather than relying on what was underneath.
    let style = background.patch(Style::default().fg(border_color));
    let (glyph, glyph_width) = match dir.axis() {
        window::Axis::Col => ("│", 1u16),
        window::Axis::Row => ("─", 1u16),
    };
    let buf = frame.buffer_mut();
    match dir.axis() {
        window::Axis::Col => {
            // sep_rect is a single column; iterate rows.
            for row in sep_rect.y..sep_rect.y + sep_rect.height {
                if let Some(cell) = buf.cell_mut((sep_rect.x, row)) {
                    *cell = Cell::default();
                    cell.set_symbol(glyph);
                    cell.set_style(style);
                }
            }
        }
        window::Axis::Row => {
            // sep_rect is a single row; iterate columns.
            let mut col = sep_rect.x;
            while col < sep_rect.x + sep_rect.width {
                if let Some(cell) = buf.cell_mut((col, sep_rect.y)) {
                    *cell = Cell::default();
                    cell.set_symbol(glyph);
                    cell.set_style(style);
                    col += glyph_width;
                } else {
                    break;
                }
            }
        }
    }
}

/// Walk the layout tree and render each leaf window into its allocated rect.
/// Takes `&mut LayoutTree` so that Split nodes can record their `last_rect`
/// for use by resize commands in later phases.
///
/// The geometry itself — where each child goes, where the separator goes, and
/// how a [`hjkl_layout::Fixed`] allocation overrides `ratio` — comes from
/// [`hjkl_layout::split_geometry`], the same function
/// [`hjkl_layout::LayoutTree::window_rects`] and `mouse::hit_test_border` use.
/// The renderer descends the tree itself (rather than calling `window_rects`,
/// which returns a flat leaf list) because it has to draw the separator at
/// every internal node and record each split's `last_rect` on the way down.
fn render_layout(frame: &mut Frame, app: &mut App, area: Rect, layout: &mut window::LayoutTree) {
    match layout {
        window::LayoutTree::Leaf(id) => render_window(frame, app, area, *id),
        window::LayoutTree::Split {
            dir,
            ratio,
            fixed,
            a,
            b,
            last_rect,
        } => {
            // Record the FULL rect (pre-separator) so that resize commands and
            // border drags can convert line/column deltas back to this split's
            // geometry — for fixed splits too, since `split_geometry` derives
            // both children from exactly this rect.
            *last_rect = Some(window::rect_to_layout(area));

            let border_color = app.theme.ui.border;
            let geo =
                hjkl_layout::split_geometry(window::rect_to_layout(area), *dir, *ratio, *fixed);
            let rect_a = window::layout_to_rect(geo.a);
            let rect_b = window::layout_to_rect(geo.b);
            let sep_rect = geo.separator.map(window::layout_to_rect);

            render_layout(frame, app, rect_a, a);
            render_layout(frame, app, rect_b, b);

            // Draw separator on top of both children after they render so
            // that no window content bleeds into the separator cell.
            if let Some(sep) = sep_rect {
                draw_separator(frame, sep, *dir, border_color, frame_background_style(app));
            }
        }
        // `LayoutTree` is `#[non_exhaustive]`; unknown variant → skip rendering.
        _ => {}
    }
}

/// Render a single window occupying `area`.
fn render_window(frame: &mut Frame, app: &mut App, area: Rect, win_id: window::WindowId) {
    // Record the rendered rect for Phase 2+ direction navigation.
    if let Some(win) = app.windows[win_id].as_mut() {
        win.last_rect = Some(window::rect_to_layout(area));
    }

    // Extract window metadata (then drop the borrow so we can access slots).
    let (slot_idx, is_focused) = {
        // Closed window — skip.
        let Some(win) = app.windows[win_id].as_ref() else {
            return;
        };
        (win.slot, win_id == app.focused_window())
    };

    // 1-col left/right padding for the file list so it isn't flush against
    // the pane edges.
    let mut area = area;
    if app.slots()[slot_idx].is_explorer() && area.width >= 2 {
        area.x += 1;
        area.width -= 2;
    }

    // Per-window state (#151 Phase D): settings, cursor, viewport, blame-view
    // come from THIS window's editor. Buffer + syntax spans + per-buffer
    // metadata (blame data, diag/git signs) stay on the slot editor (shared).
    // `is_blame` has no slot-level counterpart (#151 Stage 2b — it's
    // transient per-window UI state, never meant to persist beyond a
    // window's life); default it off in the (should-not-happen) fallback.
    let (w_cursor_row, w_cursor_col, w_is_blame) = app.window_editors.get(&win_id).map_or_else(
        || {
            let c = app.slots()[slot_idx].buffer().cursor();
            (c.row, c.col, false)
        },
        |e| {
            let c = e.buffer().cursor();
            (c.row, c.col, e.is_blame())
        },
    );
    // Scalar (Copy) settings copied out of THIS window's editor (or the slot
    // fallback) without cloning the whole `Settings` struct (#312). The owned
    // `listchars: ListChars` field is borrowed further below — AFTER the
    // mutable-viewport block — so its immutable `app` borrow doesn't collide
    // with the `get_mut` there.
    let (
        nu,
        rnu,
        cul,
        cuc,
        list_active,
        indent_guides_enabled,
        indent_guide_char,
        indent_guide_shiftwidth,
        indent_guide_tabstop,
        diagnostics_inline_mode,
        blame_inline,
        fold_enabled,
    ) = {
        let st = app
            .window_editors
            .get(&win_id)
            .map_or_else(|| app.slots()[slot_idx].settings(), |e| e.settings());
        (
            st.number,
            st.relativenumber,
            st.cursorline,
            st.cursorcolumn,
            st.list,
            st.indent_guides,
            st.indent_guide_char,
            st.shiftwidth,
            st.tabstop,
            st.diagnostics_inline,
            st.blame_inline,
            st.foldenable,
        )
    };

    // Stable sign + fold columns: reserve the max width each would need across
    // ALL open buffers, so a diagnostic/git sign (or fold) appearing in one
    // buffer doesn't shift the text column when switching buffers or scrolling.
    // The explorer pane is gutterless.
    let is_explorer_slot = app.slots()[slot_idx].is_explorer();
    let (sign_w, fold_w) = if is_explorer_slot {
        (0, 0)
    } else {
        stable_gutter_extra(app)
    };
    // Stable line-number gutter: when this buffer shows numbers, size the column
    // to the widest needed across ALL open buffers (the biggest file's line
    // count) so switching buffers doesn't shift the text column horizontally.
    let own_lnum = app
        .window_editors
        .get(&win_id)
        .map_or_else(|| app.slots()[slot_idx].lnum_width(), |e| e.lnum_width());
    let num_gw_for_text = if own_lnum > 0 { max_lnum_width(app) } else { 0 };
    // Extra padding added to the number column beyond the buffer's own width —
    // folded into the cursor's gutter offset below so the caret stays aligned.
    let lnum_pad = num_gw_for_text.saturating_sub(own_lnum);
    let gw = sign_w + num_gw_for_text + fold_w;
    let text_width = area.width.saturating_sub(gw);

    // For the focused window: publish viewport dims into the engine so
    // scrolloff math and cursor-screen-pos work correctly.
    if is_focused {
        let tabstop = indent_guide_tabstop as u16;
        if let Some(e) = app.window_editors.get_mut(&win_id) {
            let vp = e.host_mut().viewport_mut();
            vp.width = text_width;
            vp.height = area.height;
            vp.text_width = text_width;
            vp.tab_width = tabstop;
            e.set_viewport_height(area.height);
        }
    }

    // Explorer render data (whole-buffer text, per-row byte index, conceals,
    // parsed overlay tree) — shared by the conceal pass and the glyph/color
    // overlay far below, and cached against the explorer buffer's `dirty_gen`
    // (P7): all four are whole-buffer passes that used to rerun on EVERY frame
    // the explorer was visible. `content_joined()` (not `as_string()`) so even
    // the rope→String copy hits the buffer's own per-`dirty_gen` cache.
    // Bound HERE — the cache lookup needs `&mut app`, so it must run before the
    // long-lived immutable `settings()` / `buffer_spans()` borrows below. The
    // returned handle is `Arc`-based and holds no borrow of `app`.
    let explorer_render: Option<crate::app::explorer::ExplorerRenderData> =
        if is_explorer_slot && !app.debug_mode {
            let (buffer_id, dirty_gen, text) = {
                let s = &app.slots()[slot_idx];
                (
                    s.buffer_id,
                    s.buffer().dirty_gen(),
                    s.buffer().content_joined(),
                )
            };
            let root = app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .map(|ep| ep.tree.root.clone());
            Some(
                app.explorer_render_cache
                    .get(buffer_id, dirty_gen, root.as_deref(), text),
            )
        } else {
            None
        };

    // P7: populate the engine's per-row hlsearch match cache for exactly the
    // rows this frame will paint, so `BufferView` reads cached byte ranges
    // instead of re-running the search regex on every visible line every frame.
    // The range mirrors the widget's prefetch (`viewport.top_row` for
    // `area.height` rows); the animated render top is derived the same way as
    // `vp_top` far below, so the two agree. This `&mut` borrow MUST finish
    // before the long-lived immutable `settings()` / `buffer_spans()` /
    // `search_state()` borrows taken below to build the view.
    let render_top = app
        .window_editors
        .get(&win_id)
        .map(|e| e.host().viewport().top_row)
        .unwrap_or_default();
    let populate_top = app.scroll_anim_render_top(win_id).unwrap_or(render_top);
    let populate_bot = populate_top + area.height as usize;
    if let Some(e) = app.window_editors.get_mut(&win_id) {
        e.populate_search_cache(populate_top, populate_bot);
    }

    // Refresh the `colorcolumn` memo while `app` is still mutable (the parsed
    // columns are borrowed out of it further down, after the immutable
    // `settings_ref` borrow starts). Steady state: one string compare.
    let cc_reparse = {
        let cc = app
            .window_editors
            .get(&win_id)
            .map_or_else(|| app.slots()[slot_idx].settings(), |e| e.settings())
            .colorcolumn
            .as_str();
        (app.colorcolumn_memo.0 != cc).then(|| (cc.to_string(), parse_colorcolumn(cc)))
    };
    if let Some(fresh) = cc_reparse {
        app.colorcolumn_memo = fresh;
    }

    // Borrow the owned `Settings` field (`listchars: ListChars`) for the rest
    // of the draw instead of cloning the whole struct (#312). Bound AFTER the
    // mutable-viewport block above so this immutable `app` borrow doesn't
    // collide with the `get_mut` there.
    let settings_ref = app
        .window_editors
        .get(&win_id)
        .map_or_else(|| app.slots()[slot_idx].settings(), |e| e.settings());
    let listchars_ref = &settings_ref.listchars;

    // Relative/hybrid line numbers count from THIS window's cursor row. The
    // focused window's editor cursor is authoritative and matches its saved
    // `cursor_row`; an unfocused window must use its own saved row so its
    // relative numbers don't count from the active window's cursor.
    let cursor_row = w_cursor_row;
    let numbers = match (nu, rnu) {
        (false, false) => GutterNumbers::None,
        (true, false) => GutterNumbers::Absolute,
        (false, true) => GutterNumbers::Relative { cursor_row },
        (true, true) => GutterNumbers::Hybrid { cursor_row },
    };
    // Number-column width only (sign/fold widths are tracked separately).
    // Uses the stable cross-buffer max computed above.
    let num_gw = num_gw_for_text;
    let gutter = if num_gw > 0 || sign_w > 0 || fold_w > 0 {
        Some(Gutter {
            width: num_gw,
            style: Style::default().fg(app.theme.ui.gutter),
            line_offset: 0,
            numbers,
            sign_column_width: sign_w,
            fold_column_width: fold_w,
        })
    } else {
        None
    };

    // Viewport for this window: focused uses editor's live viewport (with
    // auto-scroll applied); non-focused builds one from the window's own
    // stored scroll position so it doesn't chase the focused editor.
    // This window's viewport comes from its own editor (#151 Phase D): focused
    // windows get live auto-scrolled scroll; non-focused windows keep the scroll
    // they were left at (their editor isn't dispatched while unfocused).
    let mut viewport_owned = app
        .window_editors
        .get(&win_id)
        .map(|e| *e.host().viewport())
        .unwrap_or_default();
    viewport_owned.width = text_width;
    viewport_owned.height = area.height;
    viewport_owned.text_width = text_width;

    // Smooth-scroll (#195): interpolate the RENDER top so the text
    // `BufferView` paints below and the overlays that key off `vp_top`
    // (signs, matchparen, hop, diff bands, the explorer devicon pass) all
    // agree on which doc rows are on screen during an active anim.
    //
    // Before this fix, `vp_top` was computed (as below) and used ONLY by
    // the overlays — `viewport_owned.top_row` (what `BufferView` actually
    // draws from, `viewport_ref` below) stayed at the FINAL target the
    // whole time. So the feature was visually inert (text never moved
    // during the anim) AND the overlays were misaligned relative to that
    // static text by up to `|target - start|` rows while a scroll was
    // mid-flight. Writing the animated top back into `viewport_owned`
    // before `viewport_ref` is taken fixes both at once.
    //
    // `target_top_row` (the real, un-animated top) is kept for the cursor
    // block further down: `Editor::cursor_screen_pos` reads the ENGINE's
    // own live viewport (a separate object from this function's local
    // `viewport_owned` snapshot, so it's untouched by the write below) and
    // has no "compute from an arbitrary top" variant — it always resolves
    // the cursor's screen row from the target top. Left alone, the cursor
    // would now be the thing that disagrees with the animated text (the
    // exact bug this fix is closing, just moved from overlays to the
    // cursor); the cursor block re-derives the screen row from `vp_top`
    // using the same fold/wrap-aware row counting the engine uses
    // (`View::screen_rows_between`) to keep it in step.
    let target_top_row = viewport_owned.top_row;
    let vp_top = app.scroll_anim_render_top(win_id).unwrap_or(target_top_row);
    viewport_owned.top_row = vp_top;
    let viewport_ref: &Viewport = &viewport_owned;

    let in_prompt = app.command_field.is_some()
        || app.filter_field.is_some()
        || app.search_field.is_some()
        || app.picker.is_some()
        || app.explorer_git_discard_confirm.is_some();

    // Merge diagnostic + LSP diag + git signs, filtered to the visible viewport.
    let vp_bot = vp_top + area.height as usize;
    // Per-window, per-frame viewport-filtered diagnostic list. Shared by the
    // two `lsp_diags` consumers below — the overlay pass and the eol-hint
    // builder — so neither re-scans the uncapped `slot.lsp_diags` list per
    // frame (they used to each filter it independently: two scans + two
    // fresh Vecs per window per frame). The sign column reads the
    // precomputed per-slot `diag_signs_lsp` (already derived + viewport
    // filtered in the merge below), not this list.
    // Kept in `lsp_diags` order (NOT sorted by row): the overlay pass paints
    // in list order, and reordering would change which of two overlapping
    // same-row diags wins the shared cells.
    let visible_diags: Vec<&crate::app::LspDiag> = app.slots()[slot_idx]
        .lsp_diags
        .iter()
        .filter(|d| row_in_viewport(d.start_row, vp_top, vp_bot))
        .collect();
    let mut visible_signs: Vec<hjkl_buffer_tui::Sign> = app.slots()[slot_idx]
        .diag_signs
        .iter()
        .copied()
        .filter(|s| s.row >= vp_top && s.row < vp_bot)
        .chain(
            app.slots()[slot_idx]
                .diag_signs_lsp
                .iter()
                .copied()
                .filter(|s| s.row >= vp_top && s.row < vp_bot),
        )
        .chain(
            app.slots()[slot_idx]
                .git_signs
                .iter()
                .copied()
                .filter(|s| s.row >= vp_top && s.row < vp_bot),
        )
        .collect();
    visible_signs.sort_by_key(|s| s.row);

    // Visual selection belongs to the focused window only. The selection is
    // editor state shared by every window on the same slot, so an unfocused
    // split would otherwise paint the active window's selection too.
    let selection = if is_focused {
        app.window_editors
            .get(&win_id)
            .and_then(|e| e.buffer_selection())
    } else {
        None
    };
    // styled_spans / style_table live on the window editor, not the slot
    // (#151 Stage 2b — see `App::install_syntax_spans_for_slot`): a buffer
    // open in N splits gets N independent installs, and a span's `style` id
    // only resolves correctly against the SAME editor's style table that
    // assigned it. `syntax_glue::recompute_and_install` installs into every
    // window showing a slot, so a live window editor always has spans by the
    // time it's focused enough to render with signs/gutter; the fallback
    // here (no window editor yet) is the same "should not happen" case as
    // elsewhere and just renders unstyled.
    let buffer_spans: &[Vec<hjkl_buffer::Span>] = app
        .window_editors
        .get(&win_id)
        .map_or(&[][..], |e| e.buffer_spans());
    // `:set nohlsearch` suppresses the highlight while leaving the pattern
    // ARMED, so `n` / `N` keep working — that is what distinguishes the option
    // from the `:nohlsearch` command, which disarms the pattern itself.
    // Dropping the pattern here (rather than downstream) turns off both the
    // per-row range cache and the match style in one place.
    let search_pattern_owned = app
        .window_editors
        .get(&win_id)
        .filter(|e| e.settings().hlsearch)
        .and_then(|e| e.search_state().pattern.clone());
    let search_pattern = search_pattern_owned.as_ref();
    // Engine-computed per-row match cache. Only expose the rows repopulated
    // THIS frame (`[populate_top, populate_bot)`), carried as `(base, covered)`
    // so the widget indexes relative to `base`. `dirty_gen` is a single global
    // counter, so a leftover `matches[row]` from a prior frame is stale after
    // any edit until repopulated; rows outside this window fall back to
    // `search_pattern` (the regex), so no highlight is ever wrong or lost.
    let search_ranges: Option<hjkl_buffer_tui::SearchRanges<'_>> = if search_pattern.is_some() {
        app.window_editors.get(&win_id).map(|e| {
            let m = e.search_state().matches.as_slice();
            let end = m.len().min(populate_bot);
            let base = populate_top.min(end);
            (base, &m[base..end])
        })
    } else {
        None
    };

    let search_bg = if search_pattern.is_some() {
        app.theme.ui.search_match_style()
    } else {
        Style::default()
    };

    // Borrow this window editor's style table (#312) instead of cloning the
    // whole `Vec<Style>` per window per frame — the resolver only reads it. The
    // borrow lives until the `BufferView` is rendered below; no `app` mutation
    // happens in between (the only `get_mut` is the viewport block far above).
    let style_table = app
        .window_editors
        .get(&win_id)
        .map_or(&[][..], |e| e.style_table());
    let resolver = |id: u32| {
        hjkl_engine_tui::style_to_ratatui(style_table.get(id as usize).copied().unwrap_or_default())
    };

    // For non-focused windows, don't show cursor highlight or cursor position.
    let show_cursor = is_focused && !in_prompt;

    // Resolve cursorline / cursorcolumn styles for this window. The cursor line
    // stays visible on UNFOCUSED windows too (e.g. the explorer's selection when
    // focus is in the editor), painted with a fainter bg and without the cursor.
    let cursor_line_style = if cul {
        if show_cursor {
            cursor_line_bg(&app.theme.ui)
        } else {
            Style::default().bg(app.theme.ui.cursor_line_inactive_bg)
        }
    } else {
        Style::default()
    };
    let cursor_column_style = if show_cursor && cuc {
        Style::default().bg(app.theme.ui.cursor_column_bg)
    } else {
        Style::default()
    };

    // Colorcolumn indices (1-based) — rendered under syntax. Parsed above,
    // once per distinct `colorcolumn` string rather than once per window per
    // frame.
    let cc_cols: &[u16] = &app.colorcolumn_memo.1;
    let cc_style = Style::default().bg(app.theme.ui.colorcolumn_bg);

    // Compute indent guide active column from the cursor row's leading whitespace.
    // The active column is the deepest guide column at the cursor's indent level:
    //   active_col = floor((leading_vcols - 1) / sw) * sw
    // Returns None when sw == 0 or the cursor row has no leading whitespace.
    // Only the focused window highlights its active indent column — it keys off
    // the shared editor cursor, so an unfocused window would otherwise track the
    // active window's cursor instead of staying put.
    let indent_guide_active_col: Option<usize> =
        if is_focused && indent_guides_enabled && indent_guide_shiftwidth > 0 {
            let cursor_row = w_cursor_row;
            let rope = app.slots()[slot_idx].buffer().rope();
            let cursor_line = hjkl_buffer::rope_line_str(&rope, cursor_row);
            let tab_width = indent_guide_tabstop.max(1);
            let leading_vcols = hjkl_buffer::leading_visual_width(&cursor_line, tab_width);
            if leading_vcols >= indent_guide_shiftwidth {
                // paint_row paints guides at sw, 2*sw, ... while
                // `guide_col < leading_vcols`. Deepest painted is
                // ((L - 1) / sw) * sw.
                let level = (leading_vcols - 1) / indent_guide_shiftwidth;
                Some(level * indent_guide_shiftwidth)
            } else {
                None
            }
        } else {
            None
        };

    let diag_overlays = build_diag_overlays(&visible_diags, &app.theme.ui);

    // Inline end-of-line ghost text, rendered comment-style (`// …` in
    // Rust/JS, `# …` in Python, …) so it reads like a trailing comment, with
    // per-hint colors. Two sources:
    //   1. LSP diagnostics — `// message` in the severity color. Mode is
    //      `:set diagnostics_inline=off|current|all` (default `all`): `all`
    //      annotates every diagnostic line, `current` only the cursor line.
    //      One hint per line (most-severe diagnostic wins).
    //   2. Inline git blame — `// author · summary` dimmed on the cursor line,
    //      idle-gated so it doesn't flicker while moving (#202 P5). Shown only
    //      when the cursor line has no diagnostic hint.
    use hjkl_engine::types::DiagInlineMode;
    const BLAME_IDLE_DELAY: std::time::Duration = std::time::Duration::from_millis(400);
    use hjkl_buffer_tui::render::EolHint;
    let comment_lead = app.active_comment_lead();
    let eol_hints: Vec<EolHint> = {
        let slot = &app.slots()[slot_idx];
        let cursor_row = w_cursor_row;
        let diag_mode = diagnostics_inline_mode;

        let mut hints: Vec<EolHint> = Vec::new();

        // 1. Diagnostics — pick the most-severe diagnostic per (start) line.
        if is_focused && diag_mode != DiagInlineMode::Off {
            let mut by_row: std::collections::HashMap<usize, (DiagSeverity, &str)> =
                std::collections::HashMap::new();
            for d in &visible_diags {
                // `visible_diags` is already clipped to `[vp_top, vp_bot)`
                // (audit D2) — no per-diag viewport check needed here. Safe
                // for `Current` mode too: the focused cursor row is always
                // inside `[vp_top, vp_bot)`.
                if diag_mode == DiagInlineMode::Current && d.start_row != cursor_row {
                    continue;
                }
                let msg = d.message.lines().next().unwrap_or("");
                by_row
                    .entry(d.start_row)
                    .and_modify(|cur| {
                        if diag_severity_rank(d.severity) < diag_severity_rank(cur.0) {
                            *cur = (d.severity, msg);
                        }
                    })
                    .or_insert((d.severity, msg));
            }
            for (row, (severity, msg)) in by_row {
                hints.push(EolHint {
                    row,
                    text: format!("{comment_lead} {}", truncate_chars(msg, 80)),
                    style: Style::default()
                        .fg(diag_severity_fg(severity))
                        .add_modifier(Modifier::ITALIC),
                });
            }
        }

        // 2. Inline blame on the cursor line, unless a diagnostic already
        //    annotates it (errors take precedence over blame).
        let blame_show = blame_inline
            && !w_is_blame
            && is_focused
            && app.blame_cursor_moved_at.elapsed() >= BLAME_IDLE_DELAY
            && !hints.iter().any(|h| h.row == cursor_row);
        if blame_show && let Some(Some(info)) = slot.blame.get(cursor_row) {
            let body = if info.is_uncommitted {
                "You \u{00b7} Not Committed Yet".to_string()
            } else {
                format!(
                    "{} \u{00b7} {}",
                    info.author,
                    truncate_chars(&info.summary, 50)
                )
            };
            hints.push(EolHint {
                row: cursor_row,
                text: format!("{comment_lead} {body}"),
                style: Style::default().fg(app.theme.ui.non_text),
            });
        }

        hints
    };

    // Boxed-blame layout: when the blame view is on (Wrap::None), build a
    // render plan that frames each commit run in a box (titled top border,
    // `│` sides, bottom border). The engine's cursor/scroll stays authoritative
    // — this only changes how the viewport is painted.
    let box_mode = w_is_blame && matches!(viewport_owned.wrap, hjkl_buffer::Wrap::None);
    let blame_box_plan: Vec<hjkl_buffer_tui::render::BlameRow> = if box_mode {
        let s = &app.slots()[slot_idx];
        let vp_top = viewport_owned.top_row;
        let line_count = s.buffer().line_count() as usize;
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or(0, |d| d.as_secs() as i64);
        let buf = s.buffer();
        crate::app::git_hunks::build_blame_box_plan(
            &s.blame,
            line_count,
            |r| buf.is_row_hidden(r),
            vp_top,
            area.height as usize,
            now,
        )
    } else {
        Vec::new()
    };

    // ── Explorer conceals: hide the <US><id> tail on each non-root line ─────
    //
    // When this slot is the explorer and debug_mode is off, there is one Conceal
    // per buffer line that contains a Unit Separator (U+001F). The conceal
    // covers [byte of US .. end of line] with an empty replacement so the tail
    // is invisible. The buffer text itself is unchanged; only the rendered cells
    // differ. In debug_mode the raw ids are shown for diagnostics.
    // Built (with the rest of the explorer render data) above.
    let explorer_conceals: &[hjkl_buffer_tui::Conceal] = explorer_render
        .as_ref()
        .map_or(&[][..], |d| d.conceals.as_slice());

    // Diff-mode filler plan (#250): blank rows that keep the two diff windows
    // aligned. Built once and reused by the renderer, the cursor placement, and
    // the diff-band overlay so all three agree on screen rows.
    let diff_filler_plan = app.diff_filler_plan(win_id);

    // `:set nofoldenable` (vim's `zn`) shows every line while KEEPING each
    // fold's closed state, so `zi` restores exactly what was folded. Render an
    // all-open copy of this window's folds; the stored folds are untouched.
    // Only allocated while foldenable is off — normally this is an empty Vec.
    let open_folds: Vec<hjkl_buffer::Fold> = if fold_enabled {
        Vec::new()
    } else {
        let mut fs = if is_focused {
            app.slots()[slot_idx].buffer().folds()
        } else {
            app.window_folds.get(&win_id).cloned().unwrap_or_default()
        };
        for f in &mut fs {
            f.closed = false;
        }
        fs
    };

    let view = BufferView {
        buffer: app.slots()[slot_idx].buffer(),
        viewport: viewport_ref,
        selection,
        resolver: &resolver,
        cursor_line_bg: cursor_line_style,
        // Cursorline row = THIS window's own editor cursor (`w_cursor_row`), the
        // same single source of truth (#151) that relative line numbers, indent
        // guides, and EOL hints above already key off. It resolves correctly for
        // both cases: the focused window's authoritative cursor is its window
        // editor, and each unfocused split keeps its own saved cursor — so the
        // ghost line stays put when a sibling window on the same buffer moves.
        //
        // Passing it explicitly (rather than `None` → the renderer's slot-buffer
        // fallback) matters because `BufferView.buffer` is the SHARED slot
        // editor's buffer, whose cursor is a distinct object from the focused
        // window editor's cursor; the two can diverge, and the fallback would
        // then paint the cursorline on the wrong row.
        cursor_line_row: Some(w_cursor_row),
        // Same reason as `cursor_line_row` directly above, for the other axis.
        // Without it the bar reads the shared slot buffer's cursor, which no
        // window moves — so `:set cuc` painted column 0 for the whole session.
        cursor_column: Some(w_cursor_col),
        // The explorer is a tree, not code — don't tint folded directory rows.
        fold_line_bg: if is_explorer_slot {
            Style::default()
        } else {
            Style::default().bg(app.theme.ui.fold_line_bg)
        },
        // Unfocused windows render their OWN fold snapshot (window-level folds);
        // the shared buffer holds only the focused window's set. Focused window:
        // `None` reads the live `buffer.folds()`. Under `:set nofoldenable`
        // both go through `open_folds` instead — see its definition.
        folds_override: if !fold_enabled {
            Some(open_folds.as_slice())
        } else if is_focused {
            None
        } else {
            app.window_folds.get(&win_id).map(Vec::as_slice)
        },
        cursor_column_bg: cursor_column_style,
        selection_bg: Style::default().bg(Color::Blue),
        cursor_style: Style::default(),
        gutter,
        search_bg,
        signs: &visible_signs,
        conceals: explorer_conceals,
        spans: buffer_spans,
        search_pattern,
        search_ranges,
        non_text_style: Style::default().fg(app.theme.ui.non_text),
        show_eob: app.slots()[slot_idx].features.end_of_buffer,
        diag_overlays: &diag_overlays,
        colorcolumn_cols: cc_cols,
        colorcolumn_style: cc_style,
        listchars: if list_active {
            Some(listchars_ref)
        } else {
            None
        },
        indent_guides_enabled,
        indent_guide_char,
        indent_guide_shiftwidth,
        indent_guide_fg: app.theme.ui.indent_guide_fg,
        indent_guide_active_fg: app.theme.ui.indent_guide_active_fg,
        indent_guide_active_col,
        // Inline EOL ghost text (cursor line): diagnostics + git blame.
        eol_hints: &eol_hints,
        blame_plan: if box_mode {
            Some(&blame_box_plan)
        } else {
            None
        },
        diff_filler: diff_filler_plan.as_ref(),
        // Terminal-background painting (#303): fill the text area + gutter with
        // the theme's editor background so the terminal's own bg doesn't show
        // through. `transparent = true` in config clears it (no fill), restoring
        // vim's see-through behaviour.
        background: buffer_background_style(app),
    };
    frame.render_widget(view, area);

    // ── Explorer devicon + git-status color overlay ───────────────────────
    //
    // Post-render cell walk applied only to the window matching the explorer
    // pane's win_id so multiple windows on the same buffer are not
    // double-painted.
    //
    // The buffer now contains only indentation spaces + bare names (no glyphs).
    // This pass does two things in one loop:
    //   1. PAINT GLYPHS — write the tree-guide, connector, and icon characters
    //      into the leading blank cells that `render_text` left empty.
    //   2. PAINT COLORS — repaint the icon cell with a devicon/dir RGB fg and
    //      every name cell with the git-status color (or devicon fg for clean).
    //
    // Column layout (depth-0 root / depth ≥ 1):
    //   depth 0 : col 0 = icon, col 1 = space, col 2.. = name
    //   depth ≥ 1:
    //     col i*2        (i in 0..branches.len()) = '│' or space  (guide)
    //     col i*2+1                                = space
    //     col (depth-1)*2   = '└' or '├'          (connector)
    //     col (depth-1)*2+1 = '╴'
    //     col depth*2       = icon
    //     col depth*2+1     = space
    //     col depth*2+2..   = name
    // `:debug` mode renders the explorer as its RAW buffer text (no glyph /
    // guide / git-color overlay) so the actual on-disk buffer contents are
    // visible for debugging.
    if is_explorer_slot
        && !app.debug_mode
        && let Some(ref pane) = app.tabs[app.active_tab].explorer
        && pane.win_id == win_id
        && let Some(ref ex_render) = explorer_render
    {
        let buf = frame.buffer_mut();
        let screen_top = area.y;
        let screen_height = area.height as usize;
        // Explorer is gutterless: text starts at area.x (sign_w=fold_w=num_gw=0).
        let text_x = area.x;

        // The viewport top row for this window (same variable as the BufferView uses).
        let vp_top_ex = vp_top;

        // Dim color used for tree guides and connectors.
        let guide_fg = app.theme.ui.indent_guide_fg;

        // Build the fold list + live buffer text once. The fold list MUST match
        // what `BufferView` rendered with: a FOCUSED window draws from the live
        // `buffer.folds()`, an UNFOCUSED one from its `window_folds` snapshot
        // (window-level folds). Reading the wrong source here desyncs the glyph
        // overlay from the drawn rows and garbles the tree.
        // Read from THIS window's own slot, not the first explorer-kind slot
        // anywhere: explorers are per-tab (#63 Phase 3), so a scan would
        // render one tab's tree against another tab's folds.
        let explorer_folds: Vec<hjkl_buffer::Fold> = {
            let b = app.slots()[slot_idx].buffer();
            if is_focused {
                b.folds()
            } else {
                app.window_folds
                    .get(&win_id)
                    .cloned()
                    .unwrap_or_else(|| b.folds())
            }
        };
        // Layout (icons, guides, depth) is derived from the LIVE buffer text —
        // NOT the last-reconciled `pane.tree.nodes` — so glyphs stay aligned
        // while the buffer is being edited (a mid-edit `o`/`O` shifts rows
        // before the Normal-mode reconcile rebuilds the tree). `None` slots are
        // blank lines (e.g. a fresh open-line awaiting a name). Parsed once per
        // `dirty_gen` (P7) rather than once per frame; the parse is a pure
        // function of the buffer text plus the tree root, both of which are the
        // cache key.
        let overlay_nodes = &ex_render.overlay_nodes;

        // Git status follows the PATH, not the row: resolve each row's color
        // from the reconciled tree's status map (which carries rollup + the
        // dirty-buffer overlay). New / renamed paths absent from the map render
        // clean until the next reconcile.
        let git_map: std::collections::HashMap<&std::path::Path, hjkl_app::git::ExplorerGit> = pane
            .tree
            .nodes
            .iter()
            .filter_map(|n| n.git.map(|g| (n.path.as_path(), g)))
            .collect();

        // Walk doc rows starting at vp_top, skipping hidden rows, collecting
        // up to `screen_height` visible rows — mirroring BufferView's render
        // loop (crates/hjkl-buffer-tui/src/render.rs) which skips
        // rows where any fold f.hides(row).
        // One merged-interval index for the whole walk: each per-row
        // "is this doc row hidden" query is O(log F) instead of a linear
        // scan of the fold list per row.
        let explorer_fold_index = FoldIndex::new(&explorer_folds);
        let total_nodes = overlay_nodes.len();
        let mut doc_row = vp_top_ex;
        let mut screen_row_idx: usize = 0;

        while doc_row < total_nodes && screen_row_idx < screen_height {
            // Skip rows hidden by a closed fold.
            if explorer_fold_index.hides_row(doc_row) {
                doc_row += 1;
                continue;
            }

            let buf_row = doc_row;
            let screen_row = screen_top + screen_row_idx as u16;
            if screen_row >= screen_top + area.height {
                break;
            }
            screen_row_idx += 1;
            doc_row += 1;

            let Some(node) = overlay_nodes.get(buf_row).and_then(|o| o.as_ref()) else {
                continue;
            };
            // Git status by path (overlay parse leaves `node.git` = None).
            let node_git = git_map.get(node.path.as_path()).copied();

            let right = area.x + area.width;

            // ── Glyph painting ────────────────────────────────────────────

            // Drive the dir open/closed icon from the tree's `expanded` set. The
            // lazy explorer has no buffer folds — a collapsed dir's children are
            // simply absent — so the icon reads expansion state directly.
            let is_expanded = app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .is_some_and(|ep| ep.tree.is_expanded(node.path.as_path()));

            // Icon character for this node.
            let icon_ch = if node.is_dir {
                hjkl_icons::dir_icon_for_path(&node.path, is_expanded, app.icon_mode)
            } else {
                hjkl_icons::file_icon_for_path(&node.path, app.icon_mode)
            };

            if node.depth == 0 {
                // Root: icon at col 0, space at col 1.
                let icon_abs = text_x;
                if icon_abs < right
                    && let Some(cell) = buf.cell_mut((icon_abs, screen_row))
                {
                    cell.set_symbol(&icon_ch.to_string());
                }
                // col 1 is already space from the buffer — nothing to write.
            } else {
                // Guide columns: for each ancestor level.
                for (i, &has_sibling) in node.branches.iter().enumerate() {
                    let col = text_x + (i as u16) * 2;
                    if col < right
                        && let Some(cell) = buf.cell_mut((col, screen_row))
                    {
                        let sym = if has_sibling { "│" } else { " " };
                        cell.set_symbol(sym);
                        cell.set_fg(guide_fg);
                    }
                    // col+1 stays space — leave it.
                }

                // Connector: at col (depth-1)*2 and (depth-1)*2+1.
                let connector_col = text_x + (node.branches.len() as u16) * 2;
                if connector_col < right
                    && let Some(cell) = buf.cell_mut((connector_col, screen_row))
                {
                    let sym = if node.is_last { "└" } else { "├" };
                    cell.set_symbol(sym);
                    cell.set_fg(guide_fg);
                }
                let connector_col2 = connector_col + 1;
                if connector_col2 < right
                    && let Some(cell) = buf.cell_mut((connector_col2, screen_row))
                {
                    cell.set_symbol("╴");
                    cell.set_fg(guide_fg);
                }

                // Icon: at col depth*2 (= name_col - 2).
                let icon_abs = text_x + node.depth as u16 * 2;
                if icon_abs < right
                    && let Some(cell) = buf.cell_mut((icon_abs, screen_row))
                {
                    cell.set_symbol(&icon_ch.to_string());
                }
                // col depth*2+1 is the space between icon and name — leave it.
            }

            // ── Color painting ────────────────────────────────────────────

            // Compute icon and name screen-column offsets.
            // Layout (same as what render_text used to emit):
            //   depth-0 (root): icon(1) + space(1) + name(...)
            //   depth>0: branches*(2) + connector(2) + icon(1) + space(1) + name(...)
            let (icon_col, name_col) = if node.depth == 0 {
                (text_x, text_x + 2)
            } else {
                let guide_cols = node.branches.len() as u16 * 2;
                let icon_off = guide_cols + 2; // +2 for the connector (e.g. `├╴`)
                (text_x + icon_off, text_x + icon_off + 2)
            };

            // Icon color: devicon per-filetype or folder blue. Falls back to the
            // theme text color so the icon ALWAYS gets a definite fg — otherwise
            // a colorless devicon (e.g. a generic file) would inherit whatever
            // the cursorline painted underneath, making the icon change color
            // depending on which row the cursor sits on.
            let icon_color = if node.is_dir {
                let name = node.path.file_name().and_then(|n| n.to_str());
                rgb(hjkl_icons::dir_color(name))
            } else {
                rgb(hjkl_icons::file_color_for_path(&node.path))
            }
            .unwrap_or(app.theme.ui.text);

            // Git status → name BACKGROUND (so the filetype/dir foreground stays
            // distinct). Clean nodes keep their devicon/dir foreground and the
            // normal background.
            let git_bg = match node_git {
                Some(hjkl_app::git::ExplorerGit::Modified) => Some(GIT_MODIFIED),
                Some(hjkl_app::git::ExplorerGit::Staged) => Some(GIT_STAGED),
                Some(hjkl_app::git::ExplorerGit::Deleted) => Some(GIT_DELETED),
                Some(hjkl_app::git::ExplorerGit::Untracked) => Some(GIT_UNTRACKED),
                None => None,
            };
            // Foreground for the name when clean: devicon (files) / folder (dirs),
            // falling back to the theme text color.
            let clean_name_fg = if node.is_dir {
                rgb(hjkl_icons::dir_color(
                    node.path.file_name().and_then(|n| n.to_str()),
                ))
            } else {
                rgb(hjkl_icons::file_color_for_path(&node.path))
            }
            .unwrap_or(app.theme.ui.text);

            // Repaint the icon cell foreground (icon keeps its filetype/dir color
            // regardless of git state — the icon column never gets a git bg).
            // Always set a definite fg so the icon never inherits the
            // cursorline's foreground on the cursor row.
            if icon_col < right
                && let Some(cell) = buf.cell_mut((icon_col, screen_row))
            {
                cell.set_fg(icon_color);
            }

            // Name span width = the ACTUAL displayed name on this row: the live
            // line text after the indent, up to the concealed `<US><id>` tail.
            // Deriving it from the buffer (not `path.file_name()`) keeps the
            // git-bg / clean-color highlight tight AND correct mid-edit when the
            // typed name contains a `/` (e.g. `somedir/test.txt`) — there
            // `file_name()` would be `test.txt` and only cover part of the name,
            // leaving the rest mis-colored. The trailing `/` on a dir name is
            // part of the line text, so it's counted naturally.
            let indent_chars = name_col.saturating_sub(text_x) as usize;
            let name_len = ex_render.line(buf_row).map_or(0, |line| {
                line.chars()
                    .skip(indent_chars)
                    .take_while(|&c| c != '\u{1F}')
                    .count() as u16
            });
            let name_end = name_col.saturating_add(name_len).min(right);

            // Repaint name cells.
            for col in name_col..name_end {
                if let Some(cell) = buf.cell_mut((col, screen_row)) {
                    match git_bg {
                        Some(bg) => {
                            cell.set_bg(bg);
                            cell.set_fg(GIT_TEXT);
                        }
                        None => {
                            cell.set_fg(clean_name_fg);
                        }
                    }
                }
            }
        }
    }

    // ── Auto-indent flash overlay ─────────────────────────────────────────
    //
    // Approach: post-render ratatui buffer walk. After `BufferView` has painted
    // all cells, we overwrite the bg of every cell on flash rows that are
    // currently visible. Instant on, instant off — no fade (per UX spec).
    // The overlay is painted only on the focused window to avoid a visually
    // confusing multi-pane flash.
    //
    // We call `indent_flash_active` on the shared App state here rather than
    // passing a pre-computed value so the expiry check happens at render time
    // (the same instant the user sees the frame).
    if is_focused && let Some((flash_top, flash_bot)) = app.indent_flash_active() {
        let flash_bg = app.theme.ui.indent_flash_bg;
        let flash_style = Style::default().bg(flash_bg);
        let buf = frame.buffer_mut();
        let screen_top = area.y;
        let screen_height = area.height;
        // Constrain to the TEXT area only — the flash overlay must not bleed
        // into the gutter. The buffer renderer paints text starting at
        // `area.x + sign_w + num_gw` (dedicated sign column + number column).
        // Without this, the gutter and text area share the same flash bg
        // and the cursor visually appears to sit "in the gutter".
        let text_x = area.x + sign_w + num_gw + fold_w;
        let text_right = area.x + area.width;
        // Clamp flash range to visible rows.
        let vis_start = flash_top.max(vp_top);
        let vis_end = flash_bot.min(vp_top + screen_height as usize - 1);
        for buf_row in vis_start..=vis_end {
            let screen_row = screen_top + (buf_row - vp_top) as u16;
            if screen_row >= screen_top + screen_height {
                break;
            }
            for col in text_x..text_right {
                let cell = buf.cell_mut((col, screen_row));
                if let Some(c) = cell {
                    c.set_style(c.style().patch(flash_style));
                }
            }
        }
    }

    // ── Diff-mode overlay (#208 Phase 2) ──────────────────────────────────
    // Post-render pass: tint changed/added lines (DiffChange/DiffAdd) and the
    // changed character ranges within them (DiffText), for both windows of an
    // active diff pair. Colors are hardcoded (vim-like dark palette) pending a
    // theme promotion. Filler-line alignment is a separate concern.
    if app.is_diff_window(win_id) {
        // Classify only the lines this frame can actually paint. Filler rows
        // only ever push a line *down* the screen (`screen_offset` adds the
        // filler count above it), so a line at or past `vp_top + height` can
        // never land on-screen — clipping here drops exactly the rows the loop
        // below used to `continue` past, after paying for their rope reads and
        // char-level diff.
        let classes = app.diff_line_classes(win_id, vp_top..vp_top + area.height as usize);
        if !classes.is_empty() {
            // Vim-like dark diff palette (bg only; syntax fg is preserved).
            let add_bg = Color::Rgb(32, 51, 32); // DiffAdd
            let change_bg = Color::Rgb(32, 42, 60); // DiffChange
            let text_bg = Color::Rgb(48, 78, 110); // DiffText (stronger)
            let text_x = area.x + sign_w + num_gw + fold_w;
            let text_right = area.x + area.width;
            let screen_top = area.y;
            let screen_height = area.height as usize;
            // One O(1) rope snapshot for the whole DiffText pass instead of a
            // fresh lock + Arc-clone per changed row.
            let rope = hjkl_engine::Query::rope(app.slots()[slot_idx].buffer());
            let buf = frame.buffer_mut();
            for (&line, class) in &classes {
                if line < vp_top {
                    continue;
                }
                // Account for filler rows inserted above this line so the band
                // lands on the same screen row the renderer painted it.
                let off = match diff_filler_plan.as_ref() {
                    Some(p) => p.screen_offset(vp_top, line),
                    None => line - vp_top,
                };
                if off >= screen_height {
                    continue;
                }
                let screen_row = screen_top + off as u16;
                let band_bg = match class.band {
                    crate::app::diff_mode::DiffBand::Add => add_bg,
                    crate::app::diff_mode::DiffBand::Change => change_bg,
                };
                for col in text_x..text_right {
                    if let Some(c) = buf.cell_mut((col, screen_row)) {
                        c.set_style(c.style().patch(Style::default().bg(band_bg)));
                    }
                }
                // Char-level DiffText over the changed byte ranges.
                if !class.text_ranges.is_empty() {
                    let lt = hjkl_buffer::rope_line_str(&rope, line);
                    let lt = lt.trim_end_matches('\n');
                    let len = lt.len();
                    for r in &class.text_ranges {
                        let start = r.start.min(len);
                        let end = r.end.min(len);
                        let cs = lt[..start].chars().count();
                        let ce = lt[..end].chars().count();
                        for ci in cs..ce {
                            let scol = text_x + ci as u16;
                            if scol >= text_right {
                                break;
                            }
                            if let Some(c) = buf.cell_mut((scol, screen_row)) {
                                c.set_style(c.style().patch(Style::default().bg(text_bg)));
                            }
                        }
                    }
                }
            }
        }
    }

    // ── Confirm-substitute active-match highlight ─────────────────────────
    // Paint inverse-video (search highlight) over the cell range of the
    // match currently being prompted. Only for the focused window so
    // inactive splits don't flash confusingly.
    if is_focused
        && let Some(cs) = app.confirming_substitute.as_ref()
        && cs.idx < cs.matches.len()
    {
        let m = &cs.matches[cs.idx];
        let match_row = m.row as usize;
        // Only paint when the row is visible in this window.
        let vp_bot = vp_top + area.height as usize;
        if match_row >= vp_top && match_row < vp_bot {
            let screen_row = area.y + (match_row - vp_top) as u16;
            // Convert byte offsets to char columns for rendering. `row` /
            // `byte_start` / `byte_end` were recorded when the match set was
            // collected and can be stale if the buffer was reloaded
            // mid-confirm: clamp the row, and fall back to the line's char
            // count when an offset no longer names a real prefix. Skipping or
            // overpainting the highlight is acceptable; panicking is not.
            let rope = hjkl_engine::Query::rope(app.slots()[slot_idx].buffer());
            let match_row = match_row.min(rope.len_lines().saturating_sub(1));
            let line = hjkl_buffer::rope_line_str(&rope, match_row);
            let line_no_nl = line.trim_end_matches('\n');
            let char_start = line_no_nl
                .get(..m.byte_start as usize)
                .map_or_else(|| line_no_nl.chars().count(), |p| p.chars().count());
            let char_end = line_no_nl
                .get(..m.byte_end as usize)
                .map_or_else(|| line_no_nl.chars().count(), |p| p.chars().count());
            let text_x = area.x + sign_w + num_gw + fold_w;
            let highlight_style = app
                .theme
                .ui
                .search_match_style()
                .add_modifier(Modifier::REVERSED);
            let buf = frame.buffer_mut();
            for ch_idx in char_start..char_end {
                let screen_col = text_x + ch_idx as u16;
                if screen_col >= area.x + area.width {
                    break;
                }
                if let Some(cell) = buf.cell_mut((screen_col, screen_row)) {
                    cell.set_style(cell.style().patch(highlight_style));
                }
            }
        }
    }

    // Map a doc `(row, col)` to a terminal cell for post-render overlays,
    // box-aware: the boxed-blame layout shifts the screen row (virtual border
    // rows) and the column (+1 box frame). `None` when the row isn't visible.
    let base_text_x = area.x + sign_w + num_gw + fold_w;
    let vp_bot_overlay = vp_top + area.height as usize;
    let map_doc_to_screen = |pair_row: usize, pair_col: usize| -> Option<(u16, u16)> {
        if box_mode {
            let idx = blame_box_plan.iter().position(
                |r| matches!(r, hjkl_buffer_tui::render::BlameRow::Buffer(d) if *d == pair_row),
            )?;
            Some((
                base_text_x + hjkl_buffer_tui::render::BLAME_BOX_FRAME_LEFT + pair_col as u16,
                area.y + idx as u16,
            ))
        } else if pair_row >= vp_top && pair_row < vp_bot_overlay {
            Some((
                base_text_x + pair_col as u16,
                area.y + (pair_row - vp_top) as u16,
            ))
        } else {
            None
        }
    };

    // Matchparen partner style: the matchparen bg, BOLD, and deliberately NOT
    // `REVERSED`. Reversed video is what paints the cursor cell, so a reversed
    // partner rendered as a second cursor block — with `%` jumping between two
    // identical blocks, there was no way to see which one you were on. Keeping
    // the cell's own fg over the muted bg reads as "related to the cursor",
    // one step quieter than the cursor itself.
    let match_paren_style = Style::default()
        .bg(app.theme.ui.match_paren_bg)
        .add_modifier(Modifier::BOLD);

    // ── matchparen bracket highlight (focused window only) ─────────────────
    if is_focused && let Some(pairs) = app.matchparen_cells() {
        let right = area.x + area.width;
        let buf = frame.buffer_mut();
        for (pair_row, pair_col) in pairs {
            // Skip the cursor's own cell so it keeps the cursor block. The
            // partner is the one that has to look different.
            if (pair_row, pair_col) == (w_cursor_row, w_cursor_col) {
                continue;
            }
            if let Some((screen_col, screen_row)) = map_doc_to_screen(pair_row, pair_col)
                && screen_col < right
                && let Some(cell) = buf.cell_mut((screen_col, screen_row))
            {
                cell.set_style(cell.style().patch(match_paren_style));
            }
        }
    }

    // ── matchparen tag highlight ──────────────────────────────────────────
    if is_focused && let Some(tag_cells) = app.matchparen_tag_cells() {
        let right = area.x + area.width;
        let buf = frame.buffer_mut();
        for (pair_row, pair_col) in tag_cells {
            // Same rule as the bracket pair: the cursor cell keeps its block,
            // every other cell of both tag names gets the muted highlight.
            if (pair_row, pair_col) == (w_cursor_row, w_cursor_col) {
                continue;
            }
            if let Some((screen_col, screen_row)) = map_doc_to_screen(pair_row, pair_col)
                && screen_col < right
                && let Some(cell) = buf.cell_mut((screen_col, screen_row))
            {
                cell.set_style(cell.style().patch(match_paren_style));
            }
        }
    }

    // ── Hop / easymotion label overlay (#197) ────────────────────────────
    // Paint each hop target's label string starting at the target's screen cell.
    // Only rendered for the window that owns the active hop state.
    if let Some(hop) = app.hop.as_ref()
        && hop.win_id == win_id
    {
        let label_style = Style::default()
            .fg(app.theme.ui.hop_label_fg)
            .bg(app.theme.ui.hop_label_bg)
            .add_modifier(Modifier::BOLD);
        let right = area.x + area.width;
        let bottom = area.y + area.height;
        // Clone the snapshot so we can call frame.buffer_mut() below.
        let targets: Vec<(usize, usize, String)> = hop
            .targets
            .iter()
            .map(|t| (t.row, t.col, t.label.clone()))
            .collect();
        let buf = frame.buffer_mut();
        for (doc_row, doc_col, label) in &targets {
            if let Some((screen_col, screen_row)) = map_doc_to_screen(*doc_row, *doc_col)
                && screen_col < right
                && screen_row < bottom
            {
                // Paint each char of the label into consecutive cells.
                for (i, ch) in label.chars().enumerate() {
                    let x = screen_col + i as u16;
                    if x >= right {
                        break;
                    }
                    if let Some(cell) = buf.cell_mut((x, screen_row)) {
                        cell.set_char(ch);
                        cell.set_style(label_style);
                    }
                }
            }
        }
    }

    // Emit the terminal cursor only for the focused window.
    // extra_gutter_width = sign_w + fold_w + lnum_pad. The engine computes the
    // cursor x from the buffer's OWN line-number width; `lnum_pad` accounts for
    // the extra cells when the number column is widened to the cross-buffer max.
    // Cursor rendering. The BLOCK (normal/visual) and UNDERLINE shapes are drawn
    // as a SOFTWARE cursor — a styled cell — because the terminal's hardware
    // cursor trails/flickers during scroll redraws (ratatui flushes the cell
    // diff BEFORE repositioning it), and held-`j`/`k` scrolling happens in those
    // modes. Both keep the underlying character visible (reversed / underlined).
    //
    // The BAR (insert) shape can't be a software cursor: a thin bar AND the
    // character can't share one cell, so painting `▏` would erase the char under
    // the cursor. Insert mode uses the real HARDWARE bar instead (positioned via
    // `set_cursor_position`), which renders between cells and leaves the char
    // intact. Insert mode has no held-scroll, so the hardware cursor doesn't
    // trail there. The command/search prompt likewise keeps the hardware cursor.
    if show_cursor
        && let Some((cx, cy)) = app.active_editor_mut().cursor_screen_pos(
            area.x,
            area.y,
            area.width,
            area.height,
            sign_w + fold_w + lnum_pad,
        )
    {
        // Boxed-blame: the cursor's screen row comes from the plan (borders
        // shift content down); the column shifts right by the box frame.
        // `None` when the cursor row scrolled past the plan's last row.
        let pos = if box_mode {
            let cur = w_cursor_row;
            blame_box_plan
                .iter()
                .position(
                    |r| matches!(r, hjkl_buffer_tui::render::BlameRow::Buffer(d) if *d == cur),
                )
                .map(|idx| {
                    (
                        cx + hjkl_buffer_tui::render::BLAME_BOX_FRAME_LEFT,
                        area.y + idx as u16,
                    )
                })
        } else if let Some(p) = diff_filler_plan.as_ref() {
            // Diff filler rows above the cursor shift it down by that many rows.
            let cur = w_cursor_row;
            if cur >= vp_top {
                let off = p.screen_offset(vp_top, cur);
                if (off as u16) < area.height {
                    Some((cx, area.y + off as u16))
                } else {
                    None
                }
            } else {
                Some((cx, cy))
            }
        } else {
            // Plain case (no box mode, no diff filler): `cy` came from
            // `cursor_screen_pos`, which resolves the cursor's screen row
            // from the ENGINE's own (target-top) viewport — see the
            // `target_top_row` comment above. Re-derive it from `vp_top`
            // instead, using the same fold/wrap-aware row counting the
            // engine itself uses, so the cursor tracks the animated text
            // rather than the target it's about to reach. A no-op once the
            // anim settles (`vp_top == target_top_row`, shift = 0).
            let shift: i64 = match vp_top.cmp(&target_top_row) {
                std::cmp::Ordering::Equal => 0,
                std::cmp::Ordering::Less => app.slots()[slot_idx].buffer().screen_rows_between(
                    viewport_ref,
                    vp_top,
                    target_top_row - 1,
                ) as i64,
                std::cmp::Ordering::Greater => {
                    -(app.slots()[slot_idx].buffer().screen_rows_between(
                        viewport_ref,
                        target_top_row,
                        vp_top - 1,
                    ) as i64)
                }
            };
            let adj_cy = cy as i64 + shift;
            if adj_cy >= area.y as i64 && adj_cy < (area.y as i64 + area.height as i64) {
                Some((cx, adj_cy as u16))
            } else {
                // Shifted row falls off the animated viewport mid-scroll —
                // skip drawing the cursor this frame rather than clamp it
                // to a misleading edge position.
                None
            }
        };
        if let Some((cx, cy)) = pos {
            let shape = app.active_editor().host().cursor_shape();
            match shape {
                hjkl_engine::CursorShape::Bar => {
                    // Hardware bar — keeps the char under the cursor visible.
                    frame.set_cursor_position((cx, cy));
                }
                hjkl_engine::CursorShape::Block => {
                    if let Some(cell) = frame.buffer_mut().cell_mut((cx, cy)) {
                        cell.set_style(Style::default().add_modifier(Modifier::REVERSED));
                    }
                }
                hjkl_engine::CursorShape::Underline => {
                    if let Some(cell) = frame.buffer_mut().cell_mut((cx, cy)) {
                        cell.set_style(Style::default().add_modifier(Modifier::UNDERLINED));
                    }
                }
            }
        }
    }
}

/// Render the completion popup, floating below the cursor position.
///
/// Only called when `app.completion.is_some()`.
fn completion_popup(frame: &mut Frame, app: &App, buf_area: Rect) {
    let Some(completion) = app.completion.as_ref() else {
        return;
    };

    // Convert buffer coordinates to screen coordinates. Anchor relative to the
    // FOCUSED window's rect (not `buf_area`) so the popup lands correctly when
    // the window is offset — e.g. the editor sits right of the explorer sidebar.
    let fw = app.focused_window();
    let win_rect = app.windows[fw].as_ref().and_then(|w| w.last_rect);
    let (base_x, base_y) = win_rect.map_or((buf_area.x, buf_area.y), |r| (r.x, r.y));
    // Per-window viewport lives on the window's own editor (#151 Phase D) —
    // per-window editors own scroll, but the SLOT bridge editor's viewport
    // stays pinned at its creation top_row. Reading the slot viewport here
    // (as this used to) anchored the popup using a stale top_row once the
    // window had scrolled independently of the slot (mouse.rs's
    // `hit_test_window` reads the window editor first for the same reason).
    let focused_editor = app.window_editor(fw);
    let vp = focused_editor.host().viewport();
    let vp_top = vp.top_row;
    // Stable cross-buffer gutter width (matches render_window) so the popup
    // anchors under the cursor even when the number/sign/fold columns are
    // widened to the open-buffer max.
    let own_lnum = focused_editor.lnum_width();
    let eff_lnum = if own_lnum > 0 { max_lnum_width(app) } else { 0 };
    let (sign_w, fold_w) = stable_gutter_extra(app);
    let gw = eff_lnum + sign_w + fold_w;

    // Cursor cell in absolute screen coordinates (0-based row relative to viewport top).
    let cursor_row = completion.anchor_row.saturating_sub(vp_top) as u16;
    let cursor_col = completion.anchor_col as u16 + gw;
    let anchor = Rect {
        x: base_x + cursor_col,
        y: base_y + cursor_row,
        width: 1,
        height: 1,
    };

    let ui = &app.theme.ui;
    let theme = hjkl_completion_tui::CompletionTheme::new(
        to_hjkl_color(ui.border_active),
        to_hjkl_color(ui.picker_selection_bg),
        to_hjkl_color(ui.text),
        to_hjkl_color(ui.text_dim),
    );
    hjkl_completion_tui::popup(frame, completion, &theme, anchor, buf_area);
}

pub fn frame(frame: &mut Frame, app: &mut App) {
    let area = frame.area();
    // Record the real terminal rect for `App::screen_rect` (fix: screen_rect
    // used to be derived from the focused window's viewport, which the
    // renderer sets to the pane's dims — wrong with any split open). This is
    // the one place that unconditionally sees the full frame every draw.
    app.last_frame_rect = Some(area);

    // Base background for the WHOLE terminal (#303 follow-up). Every widget
    // that paints its own bg covers this; what it fixes is everything that
    // does NOT — the splash screen, the gaps around the tabline and explorer
    // rows, and popup interiors — which otherwise showed the terminal's own
    // background and read as holes in the theme. Skipped under
    // `theme.transparent`, where showing the terminal through IS the point.
    let base = frame_background_style(app);
    if base != Style::default() {
        frame.buffer_mut().set_style(area, base);
    }

    // Special-pane slots (explorer, bottom qf/loclist dock, cmdline history)
    // don't count as additional user buffers for the top-bar visibility
    // decision — otherwise opening the explorer or `:copen` alone would show
    // the bar. `App::screen_rect` and `mouse::hit_test_zone` must reach the
    // same verdict from the same counter, or the geometry they derive from it
    // disagrees with what is drawn here.
    let show_top_bar = app.tabs.len() > 1 || app.real_slot_count() > 1;
    let (buf_area, status_area, top_bar_area) = {
        // Build constraint list dynamically based on what rows are visible.
        let mut constraints = Vec::new();
        if show_top_bar {
            constraints.push(Constraint::Length(TOP_BAR_HEIGHT));
        }
        constraints.push(Constraint::Min(1));
        constraints.push(Constraint::Length(STATUS_LINE_HEIGHT));

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints(constraints)
            .split(area);

        let mut idx = 0usize;
        let tb_area = if show_top_bar {
            let a = Some(chunks[idx]);
            idx += 1;
            a
        } else {
            None
        };
        let buf = chunks[idx];
        idx += 1;
        let stat = chunks[idx];
        (buf, stat, tb_area)
    };

    // Splash screen path — skip all buffer rendering while active, and take
    // the WHOLE terminal: the start screen is a title card, so a tabline or
    // status line poking out under it reads as a half-drawn editor. `area`,
    // not `buf_area`, is what makes it full-bleed (and re-centres the art on
    // the true middle of the screen).
    if let Some(ref screen) = app.start_screen {
        crate::start_screen::render(frame, area, screen, &app.theme);
        return;
    }

    // Spans are kept current by the event loop's end-of-drain flush
    // (`pending_recompute` → `recompute_and_install`). App::new seeds
    // `pending_recompute = true` so the first frame's flush handles the
    // initial parse. Calling recompute here every frame meant TWO sync
    // tree-sitter parses per draw — visible as ~half of per-keystroke
    // CPU on huge files.

    if let Some(tb) = top_bar_area {
        top_bar(frame, app, tb);
    }

    // Docks are ordinary pinned, fixed-size leaves of the tab tree (#63
    // Phase 3), so there is nothing to carve off the frame here:
    // `render_layout` places them, draws their separators and records their
    // rects exactly like any other window. All that's needed first is to
    // re-project the configured dock sizes onto the tree, since they are
    // clamped against the live frame recorded at the top of this function.
    app.sync_dock_fixed_sizes();

    // Walk the window tree and render each pane. Use take_layout /
    // restore_layout so we can pass `&mut LayoutTree` to render_layout
    // (which writes last_rect on Split nodes) while also holding
    // `&mut App` for render_window.
    let mut layout = app.take_layout();
    render_layout(frame, app, buf_area, &mut layout);
    app.restore_layout(layout);

    status_line(frame, app, status_area);

    // Picker overlay sits on top of the buffer pane. Renders last so
    // its `Clear` widget masks the editor content beneath it.
    if app.picker.is_some() {
        picker_overlay(frame, app, buf_area);
    }

    // Completion popup: command-bar popup anchored above the status line;
    // LSP/buffer popup anchored at the buffer cursor.
    if app.completion.is_some() {
        if app.command_field.is_some() {
            command_completion_popup(frame, app, status_area, buf_area);
        } else {
            completion_popup(frame, app, buf_area);
        }
    }

    // Which-key popup: shown after a prefix key idles past the configured delay.
    if app.which_key_active {
        which_key_popup(frame, app, buf_area);
    }

    // Quickfix / location-list is now a real bottom-dock window/buffer
    // (#63 Phase B), carved and rendered via `render_window` above alongside
    // the left dock — no overlay draw needed here anymore.

    // Info popup (`:reg`, `:marks`, `:jumps`, `:changes`) renders on top of
    // the picker overlay so it always shows.
    if app.info_popup.is_some() {
        info_popup_overlay(frame, app, buf_area);
    }

    // Context menu (right-click, Phase 2 Round A) — floats above everything.
    if let Some(ref menu) = app.context_menu {
        crate::menu::render(frame, menu, area, &crate::menu::MenuTheme::default());
    }

    // Hover popup (Phase 5 mouse support) — renders above all other content.
    if let Some(ref popup) = app.hover_popup {
        let hover_theme = hjkl_hover_tui::HoverTheme::new(
            app.theme.ui.border_active,
            app.theme.ui.panel_bg,
            hjkl_markdown_tui::MdTheme::default(),
        );
        hjkl_hover_tui::render(frame, popup, &hover_theme, frame.area());
    }

    // Toast notifications — float top-right, newest on top. They clear their
    // rect before drawing, so without an explicit background they punch a
    // terminal-coloured hole in the theme.
    let mut holler_layout = HollerLayout::default();
    if !app.theme_transparent {
        holler_layout = holler_layout.with_bg(app.theme.ui.panel_bg);
    }
    render_active(
        frame,
        area,
        &app.bus,
        &holler_layout,
        std::time::SystemTime::now(),
    );
}

/// Render the unified top bar into a single row.
///
/// Left side: buffer-line entries (one per slot), only when `app.slots().len() > 1`.
/// Format: ` name ` or ` name+ ` (dirty), separated by `│`.
///
/// Right side: tab entries (one per layout tab), only when `app.tabs.len() > 1`.
/// Rendered via [`hjkl_tabs_tui::build_line`] so tab-bar behaviour (active
/// highlight, dirty marker `●`, overflow `<`/`>`) is owned by `hjkl-tabs-tui`.
///
/// If both sides overflow the row width, the left (buffer) side is truncated
/// from the right with `…`. If buffers still don't fit even after truncation,
/// they are dropped entirely.
///
/// Show the row whenever EITHER side has content. When neither does, this
/// function is not called (see `render::frame`).
fn top_bar(frame: &mut Frame, app: &App, area: Rect) {
    let ui = &app.theme.ui;
    let active_style = Style::default()
        .fg(ui.on_accent)
        .bg(ui.mode_normal_bg)
        .add_modifier(Modifier::BOLD);
    let inactive_style = Style::default().fg(ui.text_dim).bg(ui.surface_bg);
    let sep_style = Style::default().fg(ui.border);

    let show_tabs = app.tabs.len() > 1;
    // Count only real (non-special-pane) slots for the buffer-line visibility check.
    let real_slot_count = (0..app.slots().len())
        .filter(|&idx| !app.slot_is_special(idx))
        .count();
    let show_buffers = real_slot_count > 1;
    let total_width = area.width as usize;

    // ── Right side: build a hjkl_tabs::TabBar from the layout tabs ──────────
    //
    // Each `hjkl_layout::Tab` (a window-split tree tab) maps to one
    // `hjkl_tabs::Tab<usize>` where the id is the positional index.
    // The title is `"{n}: {filename}"` matching the pre-migration format.
    // Dirty = any leaf window in the layout has a dirty slot.
    let mut tab_bar: TabBar<usize> = TabBar::new();
    if show_tabs {
        // `:set tabline_icons` (default on) — Nerd-Font filetype icon per tab.
        let tabline_icons = app.active_editor().settings().tabline_icons;
        for (i, layout_tab) in app.tabs.iter().enumerate() {
            let slot_idx = app.windows[layout_tab.focused_window]
                .as_ref()
                .map_or(0, |w| w.slot);
            let slot = &app.slots()[slot_idx];
            let base_name = slot
                .filename
                .as_ref()
                .and_then(|p| p.file_name())
                .and_then(|n| n.to_str())
                .unwrap_or("[No Name]");
            let title = format!("{}: {} {}", i + 1, base_name, crate::app::TAB_CLOSE_GLYPH);
            tab_bar.open(i, title);
            let tab_dirty = layout_tab.layout.leaves().iter().any(|&wid| {
                app.windows[wid]
                    .as_ref()
                    .is_some_and(|w| app.slots()[w.slot].dirty)
            });
            if let Some(t) = tab_bar.tabs.last_mut() {
                t.dirty = tab_dirty;
                // Per-filetype icon as a separate colored span (#260). Generic
                // glyph when the buffer is unnamed / extension unknown; devicon
                // color via `file_color_for_path` (None → inherits tab fg).
                // Gated on the `tabline_icons` toggle.
                if tabline_icons {
                    let glyph = match slot.filename.as_deref() {
                        Some(p) => hjkl_icons::file_icon_for_path(p, app.icon_mode),
                        None => hjkl_icons::file_icon(None, app.icon_mode),
                    };
                    t.icon = Some(glyph.to_string());
                    t.icon_color = slot
                        .filename
                        .as_deref()
                        .and_then(hjkl_icons::file_color_for_path);
                }
            }
        }
        tab_bar.focus(&app.active_tab);
    }

    // Build the tab line via hjkl-tabs-tui so active/dirty/overflow logic
    // is owned by that crate.
    let tab_theme = TabBarTheme::new(
        ui.on_accent,
        ui.mode_normal_bg,
        ui.text_dim,
        ui.surface_bg,
        ui.border,
        ui.border,
    );
    let tab_line = if show_tabs {
        build_tab_line(area.width, &tab_bar, &tab_theme)
    } else {
        hjkl_tabs_tui::build_line(0, &tab_bar, &tab_theme)
    };
    let tabs_total_len: usize = tab_line
        .spans
        .iter()
        .map(|s| s.content.chars().count())
        .sum();

    // ── Left side: compute how much width buffers can use ────────────────────
    // Tabs are right-aligned and never truncated; buffers get the remainder.
    let buf_budget = if show_buffers {
        total_width.saturating_sub(tabs_total_len)
    } else {
        0
    };

    // Build buffer spans within the budget.
    let mut buf_spans: Vec<Span<'static>> = Vec::new();
    let mut buf_used = 0usize;

    if show_buffers && buf_budget > 0 {
        let mut first = true;
        for (i, slot) in app.slots().iter().enumerate() {
            // Skip special-pane scratch buffers (explorer, bottom qf/loclist
            // dock) — real windows, but not user-visible named buffers, so
            // they must not appear in the buffer line.
            if app.slot_is_special(i) {
                continue;
            }
            let base_name = slot
                .filename
                .as_ref()
                .and_then(|p| p.file_name())
                .and_then(|n| n.to_str())
                .unwrap_or("[No Name]");
            let label = if slot.dirty {
                format!(" {}+ {} ", base_name, crate::app::TAB_CLOSE_GLYPH)
            } else {
                format!(" {} {} ", base_name, crate::app::TAB_CLOSE_GLYPH)
            };
            let sep_len = if first { 0 } else { 1 };
            let entry_width = sep_len + label.chars().count();

            if buf_used + entry_width > buf_budget {
                // Truncate with ellipsis if space remains.
                if buf_used < buf_budget {
                    buf_spans.push(Span::styled("…".to_string(), sep_style));
                }
                break;
            }

            if !first {
                buf_spans.push(Span::styled("│".to_string(), sep_style));
            }
            let style = if i == app.active_index() {
                active_style
            } else {
                inactive_style
            };
            buf_spans.push(Span::styled(label, style));
            buf_used += entry_width;
            first = false;
        }
    }

    // ── Assemble the full line ────────────────────────────────────────────────
    // Layout: [buf_spans ... padding ... tab_spans]
    // Padding fills the gap so tabs land flush-right.

    let mut all_spans: Vec<Span<'static>> = buf_spans;

    if show_tabs {
        // Pad between left and right sides.
        let used_left = buf_used;
        let pad_width = total_width.saturating_sub(used_left + tabs_total_len);
        if pad_width > 0 {
            all_spans.push(Span::raw(" ".repeat(pad_width)));
        }

        // Emit tab spans from hjkl-tabs-tui.
        all_spans.extend(tab_line.spans);
    }

    let paragraph = Paragraph::new(Line::from(all_spans));
    frame.render_widget(paragraph, area);
}

/// Build a [`PromptTheme`] from the app's [`crate::theme::UiTheme`].
///
/// Maps the form color slots to the corresponding `PromptTheme` fields so that
/// wildmenu and prompt-bar rendering respect the user's configured palette.
fn prompt_theme(ui: &crate::theme::UiTheme) -> PromptTheme {
    PromptTheme::new(
        ui.form_insert_bg,
        ui.form_normal_bg,
        ui.text,
        ui.form_tag_insert_fg,
        ui.form_tag_normal_fg,
        ui.panel_bg,
        ui.text,
        ui.picker_selection_bg,
    )
}

/// Render the completion popup anchored above the command-line row.
/// Called when both `app.command_field` and `app.completion` are `Some`.
/// The anchor rect is placed at `status_area.y` so the popup flips above it.
fn command_completion_popup(frame: &mut Frame, app: &App, status_area: Rect, viewport: Rect) {
    let Some(completion) = app.completion.as_ref() else {
        return;
    };
    // Anchor at the cursor column in the status line. The cursor column is
    // 1 (for the `:` prefix) + field cursor col.
    let cursor_col: u16 = if let Some(ref field) = app.command_field {
        let (_, col) = field.cursor();
        1u16 + col as u16
    } else {
        1
    };
    let anchor = Rect {
        x: status_area.x + cursor_col.min(status_area.width.saturating_sub(1)),
        y: status_area.y,
        width: 1,
        height: 1,
    };
    let ui = &app.theme.ui;
    let theme = hjkl_completion_tui::CompletionTheme::new(
        to_hjkl_color(ui.border_active),
        to_hjkl_color(ui.picker_selection_bg),
        to_hjkl_color(ui.text),
        to_hjkl_color(ui.text_dim),
    );
    // Use full frame area as viewport so popup can extend into buf_area above.
    hjkl_completion_tui::popup(frame, completion, &theme, anchor, viewport);
}

/// Render the one-row status line.
///
/// When the user is typing a `:` command or a `/`/`?` search, the status
/// area shows the prompt instead of the normal mode/file/cursor info, and
/// the terminal cursor is moved to the insertion point.
fn status_line(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
    let (status, cursor_col) = build_status_line(app, area.width);
    let paragraph = Paragraph::new(status);
    frame.render_widget(paragraph, area);

    // Move the terminal cursor to the insertion point in the prompt row.
    if let Some(col) = cursor_col {
        frame.set_cursor_position((area.x + col, area.y));
    }
}

/// Upper bound on matches tracked by [`search_count`] — keeps the scan and
/// the cached offset vector bounded on huge files. Both `idx` and `total`
/// saturate here; the status line renders the capped numbers verbatim.
const MATCH_CAP: usize = 10_000;

/// Count search matches in the buffer and return `(current_idx, total)`.
/// `current_idx` is 1-based (the match the cursor is on or just passed).
/// Returns `None` when no pattern is active or there are no matches.
/// Caps at [`MATCH_CAP`] matches to avoid stalling on huge files.
///
/// The *match set* is memoised on `App::search_count_cache` keyed by
/// `(buffer_id, dirty_gen, pattern_text)` — deliberately NOT the cursor,
/// which used to evict the entry on every motion and re-scan the whole
/// document each frame while a pattern was armed. On a miss the scan walks
/// `Buffer::content_joined` (cached `Arc<String>`) once with regex's
/// SIMD-fast `find_iter` and keeps only the match start offsets.
///
/// Per frame all that remains is mapping the cursor to a global byte
/// offset (O(log N) rope lookups) and a `partition_point` over the cached
/// offsets (O(log matches)).
pub fn search_count(app: &App) -> Option<(usize, usize)> {
    let st = app.active_editor().search_state();
    let pat = st.pattern.as_ref()?;
    let pattern_str = pat.as_str();

    let buf = app.active_editor().buffer();
    let buffer_id = app.active().buffer_id;
    let dirty_gen = buf.dirty_gen();
    let (cursor_row, cursor_col) = app.active_editor().cursor();

    // `cursor_col` is a char index; regex match offsets are bytes. Map the
    // cursor to a global byte offset with rope lookups only — no full scan,
    // and no per-line `String` materialisation.
    let rope = buf.rope();
    let len_lines = rope.len_lines();
    let cursor_global_byte = {
        let row = cursor_row.min(len_lines);
        let byte_in_row = if cursor_row < len_lines {
            let line = rope.line(cursor_row);
            // Exclude the line's trailing newline: a cursor column can never
            // sit on it, and the old code measured against a `\n`-stripped
            // line.
            let chars = line.len_chars();
            let chars_excl_nl = if chars > 0 && line.char(chars - 1) == '\n' {
                chars - 1
            } else {
                chars
            };
            line.char_to_byte(cursor_col.min(chars_excl_nl))
        } else {
            0
        };
        rope.line_to_byte(row) + byte_in_row
    };

    let mut cache = app.search_count_cache.borrow_mut();
    let hit = cache.as_ref().is_some_and(|c| {
        c.buffer_id == buffer_id && c.dirty_gen == dirty_gen && c.pattern == pattern_str
    });
    if !hit {
        // Single shared `Arc<String>` of the whole document — cached against
        // `dirty_gen`, so calling content_joined here costs an `Arc::clone`.
        let content = buf.content_joined();
        let mut match_starts = Vec::new();
        for m in pat.find_iter(&content) {
            match_starts.push(m.start());
            if match_starts.len() >= MATCH_CAP {
                break;
            }
        }
        *cache = Some(crate::app::SearchCountCache {
            buffer_id,
            dirty_gen,
            pattern: pattern_str.to_string(),
            match_starts,
        });
    }

    let starts = &cache.as_ref()?.match_starts;
    if starts.is_empty() {
        return None;
    }
    // `find_iter` yields non-overlapping matches in ascending order, so the
    // count of starts at-or-before the cursor IS the 1-based index of the
    // last such match (0 when the cursor precedes them all).
    let current_idx = starts.partition_point(|&s| s <= cursor_global_byte);
    Some((current_idx, starts.len()))
}

/// Build the status line text as a ratatui [`Line`].
///
/// Returns `(line, Some(cursor_col))` when a prompt is active so the
/// caller can position the terminal cursor at the insertion point.
///
/// Priority (highest first):
/// 1. Command input (user typing `:cmd`) — shows `:{typed_text}`.
/// 2. Engine search prompt (`/` or `?`) — shows `/{typed_text}`.
/// 3. Status message (ex-command result) — shown until next keypress.
/// 4. Normal mode/filename/cursor line.
fn build_status_line(app: &App, width: u16) -> (Line<'static>, Option<u16>) {
    // ── Command prompt (`:`) ─────────────────────────────────────────────────
    if let Some(ref field) = app.command_field {
        let text = field.text();
        let display: String = text.lines().next().unwrap_or("").to_string();
        let content = format!(":{display}");
        let (_, ccol) = field.cursor();
        let cursor_col = 1u16 + ccol as u16;
        let theme = prompt_theme(&app.theme.ui);
        return (
            build_prompt_line(&content, field.coarse_mode(), &theme, width),
            Some(cursor_col),
        );
    }

    // ── Filter prompt (`!`) ───────────────────────────────────────────────────
    if let Some(ref field) = app.filter_field {
        let text = field.text();
        let display: String = text.lines().next().unwrap_or("").to_string();
        let content = format!("!{display}");
        let (_, ccol) = field.cursor();
        let cursor_col = 1u16 + ccol as u16;
        let theme = prompt_theme(&app.theme.ui);
        return (
            build_prompt_line(&content, field.coarse_mode(), &theme, width),
            Some(cursor_col),
        );
    }

    // ── Host search prompt (`/` or `?`) ──────────────────────────────────────
    if let Some(ref field) = app.search_field {
        let prefix = match app.search_dir {
            crate::app::SearchDir::Forward => '/',
            crate::app::SearchDir::Backward => '?',
        };
        let text = field.text();
        let display: String = text.lines().next().unwrap_or("").to_string();
        let content = format!("{prefix}{display}");
        let (_, ccol) = field.cursor();
        let cursor_col = 1u16 + ccol as u16;
        let theme = prompt_theme(&app.theme.ui);
        return (
            build_prompt_line(&content, field.coarse_mode(), &theme, width),
            Some(cursor_col),
        );
    }

    // ── Crash-recovery prompt (issue #185) ─────────────────────────────────
    // While pending_recovery is Some, show the recovery prompt in the status
    // line (mirrors the confirm-substitute pattern).
    if let Some(pr) = app.pending_recovery.as_ref() {
        let content = format!(
            "E325: swap file found (written {} ago). Recover? [y/N/q]",
            pr.written_ago
        );
        let padded = format!("{content:<width$}", width = width as usize);
        return (
            Line::from(vec![Span::styled(
                padded,
                Style::default()
                    .bg(app.theme.ui.search_bg)
                    .fg(app.theme.ui.search_fg)
                    .add_modifier(Modifier::BOLD),
            )]),
            None,
        );
    }

    // ── Dirty-buffer disk-change prompt (issue #241) ────────────────────────
    // While pending_disk_change is Some, offer keep / reload / diff in place of
    // the normal status bar (mirrors the recovery-prompt pattern).
    if let Some(pdc) = app.pending_disk_change.as_ref() {
        let name = pdc.path.file_name().map_or_else(
            || pdc.path.to_string_lossy().into_owned(),
            |n| n.to_string_lossy().into_owned(),
        );
        let content =
            format!("W12: \"{name}\" changed on disk since editing — [k]eep / [r]eload / [d]iff");
        let padded = format!("{content:<width$}", width = width as usize);
        return (
            Line::from(vec![Span::styled(
                padded,
                Style::default()
                    .bg(app.theme.ui.search_bg)
                    .fg(app.theme.ui.search_fg)
                    .add_modifier(Modifier::BOLD),
            )]),
            None,
        );
    }

    // ── Explorer git-discard confirm ────────────────────────────────────────
    if let Some(ref path) = app.explorer_git_discard_confirm {
        let name = path.file_name().map_or_else(
            || path.to_string_lossy().into_owned(),
            |n| n.to_string_lossy().into_owned(),
        );
        let content = format!("Discard changes to {name}? (y/n)");
        let padded = format!("{content:<width$}", width = width as usize);
        return (
            Line::from(vec![Span::styled(
                padded,
                Style::default()
                    .bg(app.theme.ui.search_bg)
                    .fg(app.theme.ui.search_fg)
                    .add_modifier(Modifier::BOLD),
            )]),
            None,
        );
    }

    // ── Interactive substitute confirm (:s/pat/rep/c) ──────────────────────
    // While confirming_substitute is Some, show the per-match prompt instead
    // of the normal status bar.
    if let Some(cs) = app.confirming_substitute.as_ref() {
        let rep = if cs.idx < cs.matches.len() {
            cs.matches[cs.idx].replacement.as_str()
        } else {
            ""
        };
        let content = format!("replace with \"{rep}\"? (y/n/a/q/l)");
        let padded = format!("{content:<width$}", width = width as usize);
        return (
            Line::from(vec![Span::styled(
                padded,
                Style::default()
                    .bg(app.theme.ui.search_bg)
                    .fg(app.theme.ui.search_fg)
                    .add_modifier(Modifier::BOLD),
            )]),
            None,
        );
    }

    // ── Macro recording indicator ───────────────────────────────────────────
    // Vim shows "recording @r" while `q{reg}` is active. Render it as a
    // status-message-equivalent so it visually pre-empts the lualine row,
    // matching vim's bottom-line takeover.
    if let Some(reg) = app.active_editor().recording_register() {
        let content = format!(" recording @{reg}");
        let padded = format!("{content:<width$}", width = width as usize);
        return (
            Line::from(vec![Span::styled(
                padded,
                Style::default()
                    .bg(app.theme.ui.surface_bg)
                    .fg(app.theme.ui.text)
                    .add_modifier(Modifier::BOLD),
            )]),
            None,
        );
    }

    // ── Normal status line — delegated to hjkl-statusline ───────────────────
    // Palette + segments built in `build_normal_status_bar`; ratatui
    // conversion via `hjkl_statusline_tui::to_line`.
    (build_normal_status_bar(app, width), None)
}

/// Format the status line as a plain string (unit-test helper).
///
/// `readonly` and `is_new_file` mirror the app state flags.
/// Filename is truncated with `…` when necessary.
#[cfg(test)]
pub fn format_status_line(
    mode: &str,
    filename: &str,
    dirty: bool,
    row: usize,
    col: usize,
    total_lines: usize,
    width: u16,
) -> String {
    format_status_line_full(
        mode,
        filename,
        dirty,
        false,
        false,
        row,
        col,
        total_lines,
        width,
    )
}

/// Full status line formatter with readonly + new-file flags.
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn format_status_line_full(
    mode: &str,
    filename: &str,
    dirty: bool,
    readonly: bool,
    is_new_file: bool,
    row: usize,
    col: usize,
    total_lines: usize,
    width: u16,
) -> String {
    let dirty_marker = if dirty { "*" } else { " " };
    let ro_tag = if readonly { " [RO]" } else { "" };
    let new_tag = if is_new_file { " [New File]" } else { "" };
    let pct = ((row + 1) * 100).checked_div(total_lines).unwrap_or(0);
    let pos = format!("{}:{}", row + 1, col + 1);
    let pct_str = format!("{pct}%");
    let right = format!("{pos}  {pct_str} ");
    let left_prefix = format!(" {mode}  {dirty_marker} ");
    let suffix = format!("{ro_tag}{new_tag}");
    let w = width as usize;
    let reserved = left_prefix.len() + suffix.len() + right.len();
    let avail_for_name = w.saturating_sub(reserved);
    let truncated: String = if filename.chars().count() <= avail_for_name {
        filename.to_string()
    } else if avail_for_name <= 1 {
        String::new()
    } else {
        let keep = avail_for_name.saturating_sub(1);
        // Walk from the end to find the byte start of the last `keep` chars,
        // avoiding a panic when `filename` contains multibyte characters.
        let start_byte = filename
            .char_indices()
            .rev()
            .nth(keep.saturating_sub(1))
            .map_or(0, |(byte_idx, _)| byte_idx);
        format!("\u{2026}{}", &filename[start_byte..])
    };
    let left = format!("{left_prefix}{truncated}{suffix}");
    let used = left.len() + right.len();
    let pad_count = w.saturating_sub(used);
    let spacer = " ".repeat(pad_count);
    format!("{left}{spacer}{right}")
}

/// Format the write-success status message. Used in tests.
#[cfg(test)]
pub fn format_write_message(path: &str, lines: usize, bytes: usize) -> String {
    format!("\"{}\" {}L, {}B written", path, lines, bytes)
}

/// Render the status-line content for `app` at the given `width` and return
/// the concatenated plaintext string. Used in unit tests to assert which
/// branch (full-line banner vs normal bar) fires.
#[cfg(test)]
pub fn status_line_text(app: &App, width: u16) -> String {
    let (line, _cursor_col) = build_status_line(app, width);
    line.spans
        .iter()
        .map(|s| s.content.as_ref())
        .collect::<Vec<_>>()
        .join("")
}

/// Centered popup containing the picker's query input + scrollable
/// result list. Drawn on top of the buffer pane via `Clear` so the
/// editor content beneath is masked.
fn picker_overlay(frame: &mut Frame, app: &mut App, buf_area: Rect) {
    let area = centered_rect(80, 70, buf_area);
    frame.render_widget(Clear, area);

    let Some(p) = app.picker.as_mut() else { return };

    // Tick debounce for Spawn sources.
    p.tick(std::time::Instant::now());

    p.refresh();
    p.refresh_preview();

    const PREVIEW_MIN_WIDTH: u16 = 80;
    let with_preview = p.has_preview() && area.width >= PREVIEW_MIN_WIDTH;

    let (left_area, preview_area) = if with_preview {
        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
            .split(area);
        (cols[0], Some(cols[1]))
    } else {
        (area, None)
    };

    render_picker_input_and_list(frame, p, &app.theme.ui, left_area);
    // p (mut borrow on app.picker) ends here; below re-borrows app immutably
    // both as the picker handle and as the `PreviewHighlighter` impl.

    if let Some(right) = preview_area {
        let ui = &app.theme.ui;
        let theme = hjkl_picker_tui::PreviewTheme {
            border: Style::default().fg(ui.border),
            gutter: Style::default().fg(ui.gutter),
            non_text: Style::default().fg(ui.non_text),
            cursor_line: cursor_line_bg(ui),
        };
        // Re-borrow the picker immutably (the earlier `as_mut` borrow ended
        // above). Use `if let` rather than `expect` so a cleared picker skips
        // the preview instead of panicking the render path.
        if let Some(picker) = app.picker.as_ref() {
            hjkl_picker_tui::preview_pane(frame, picker, app, &theme, right);
        }
    }
}

/// Shared input + list rendering for the non-generic `Picker`.
fn render_picker_input_and_list(
    frame: &mut Frame,
    picker: &mut crate::picker::Picker,
    theme: &crate::theme::UiTheme,
    left_area: Rect,
) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(1)])
        .split(left_area);
    let input_area = layout[0];
    let list_area = layout[1];

    let total = picker.total();
    let matched = picker.matched();
    let scan_tag = if picker.scan_done() {
        "".to_string()
    } else {
        format!(" {} scanning", hjkl_editor_tui::spinner::frame())
    };
    let kind = picker.title();
    let title = format!(" picker — {kind} — {matched}/{total}{scan_tag} ");

    let query_text = picker.query.text();
    let display: String = query_text.lines().next().unwrap_or("").to_string();
    let input_block = Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(Style::default().fg(theme.border_active));
    let input_inner = input_block.inner(input_area);
    frame.render_widget(input_block, input_area);
    let input_para = Paragraph::new(format!("/ {display}"));
    frame.render_widget(input_para, input_inner);

    let (_, ccol) = picker.query.cursor();
    let cx = input_inner.x + 2 + ccol as u16;
    let cy = input_inner.y;
    if cx < input_inner.x + input_inner.width && cy < input_inner.y + input_inner.height {
        frame.set_cursor_position((cx, cy));
    }

    // Match-position highlight inside picker rows — uses the same orange
    // as search match highlighting.
    let match_style = Style::default()
        .fg(theme.search_bg)
        .add_modifier(Modifier::BOLD);
    // The ratatui `List` only paints the rows its scroll window shows, so
    // only those rows need `ListItem`s. Every row here is a single `Line`
    // (height 1), so the window is exactly the `list_height` rows ending at
    // `selected` — or the top rows while the selection is still among them —
    // matching the widget's own `get_items_bounds` for unit-height items.
    // `visible_rows` fetches each visible label once, instead of
    // `visible_entries` + `visible_entry_styles` fetching every label twice
    // for all 500 filtered rows on every draw.
    let filtered_count = picker.matched();
    let list_height = list_area.height.saturating_sub(2) as usize; // block borders
    let selected = picker.selected.min(filtered_count.saturating_sub(1));
    let (first, last) = if filtered_count == 0 || list_height == 0 {
        (0, 0)
    } else if selected >= list_height.min(filtered_count) {
        (selected + 1 - list_height, selected + 1)
    } else {
        (0, list_height.min(filtered_count))
    };
    let rows = picker.visible_rows(first..last);
    let items: Vec<ListItem> = rows
        .iter()
        .map(|(label, matches, styles)| {
            if matches.is_empty() && styles.is_empty() {
                return ListItem::new(label.as_str());
            }
            ListItem::new(Line::from(picker_row_spans(
                label,
                matches,
                styles,
                match_style,
            )))
        })
        .collect();
    // Keep labels for length check below.
    let label_count = items.len();
    let list_block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.border));
    let list = List::new(items).block(list_block).highlight_style(
        Style::default()
            .bg(theme.picker_selection_bg)
            .add_modifier(Modifier::BOLD),
    );
    let mut state = ListState::default();
    if label_count > 0 {
        state.select(Some(selected - first));
    }
    frame.render_stateful_widget(list, list_area, &mut state);
}

/// Build the styled spans for one picker row. Produces the exact per-char
/// rendering the picker always had — each char's style is `base.patch(match_style)`
/// when matched, `base` when the source styles it, default otherwise — but
/// adjacent chars with an identical applied style are merged into one span
/// over a borrowed slice of `label`, so no per-char `String` is allocated.
fn picker_row_spans<'a>(
    label: &'a str,
    matches: &[usize],
    styles: &[(std::ops::Range<usize>, hjkl_engine::types::Style)],
    match_style: Style,
) -> Vec<Span<'a>> {
    let mut spans: Vec<Span<'a>> = Vec::new();
    let mut run_byte_start = 0usize;
    let mut run_style: Option<Style> = None;
    for (ci, (byte_idx, _)) in label.char_indices().enumerate() {
        let base_engine = styles
            .iter()
            .find(|(r, _)| r.contains(&ci))
            .map(|(_, st)| *st)
            .unwrap_or_default();
        let base = hjkl_engine_tui::style_to_ratatui(base_engine);
        let applied = if matches.contains(&ci) {
            base.patch(match_style)
        } else {
            base
        };
        if run_style.is_some_and(|rs| rs == applied) {
            continue; // extend the current run
        }
        if let Some(rs) = run_style.take() {
            spans.push(picker_row_span(label, run_byte_start, byte_idx, rs));
        }
        run_byte_start = byte_idx;
        run_style = Some(applied);
    }
    if let Some(rs) = run_style {
        spans.push(picker_row_span(label, run_byte_start, label.len(), rs));
    }
    spans
}

/// One span for a run of identically-styled chars, borrowing `label` instead
/// of allocating a `String` per char.
fn picker_row_span<'a>(label: &'a str, start: usize, end: usize, style: Style) -> Span<'a> {
    let text = &label[start..end];
    if style == Style::default() {
        Span::raw(text)
    } else {
        Span::styled(text, style)
    }
}

/// Centered popup for multi-line `:reg` / `:marks` / `:jumps` / `:changes`
/// output and the K-key LSP hover info path. Delegates to
/// `hjkl_info_popup_tui::render` (thin shim, ≤10 LOC).
fn info_popup_overlay(frame: &mut Frame, app: &App, buf_area: Rect) {
    let Some(popup) = app.info_popup.as_ref() else {
        return;
    };
    let theme = hjkl_info_popup_tui::InfoPopupTheme::new(app.theme.ui.border_active);
    hjkl_info_popup_tui::render(frame, popup, &theme, buf_area);
}

/// Render the which-key popup anchored at the bottom of `buf_area`.
///
/// Only called when `app.which_key_active` is `true` and a prefix is pending.
fn which_key_popup(frame: &mut Frame, app: &App, buf_area: Rect) {
    if !app.which_key_active {
        return;
    }
    let pending = app.active_which_key_prefix();
    if pending.is_empty() && !app.which_key_sticky {
        return;
    }

    let leader = app.config.editor.leader;
    // The popup is Normal-mode only by design: `entries_for` is called with a
    // hardcoded `Mode::Normal` regardless of the current vim mode.
    // Non-Normal modes (Visual, Insert, etc.) suppress the popup implicitly
    // because `active_which_key_prefix` reads the Normal pending buffer of the
    // context keymap, which is always empty when the mode is not Normal.
    // When the explorer sidebar is focused, `ctx_keymap()` returns
    // `explorer_keymap` so the popup lists explorer-specific bindings.
    let entries =
        hjkl_which_key::entries_for(app.ctx_keymap(), hjkl_vim::Mode::Normal, &pending, leader);
    if entries.is_empty() {
        return;
    }

    let ui = &app.theme.ui;
    let popup_layout = hjkl_which_key::layout(&entries, buf_area.width);

    let header_label = if pending.is_empty() {
        "root".to_string()
    } else {
        hjkl_keymap::chord_to_notation(&pending, leader)
    };

    let theme = hjkl_which_key_tui::PopupTheme::new(
        ratatui_rgb_to_sl(ui.border_active),
        ratatui_rgb_to_sl(ui.text_dim),
    );

    hjkl_which_key_tui::render(frame, &popup_layout, &header_label, &theme, buf_area);
}

/// Compute a centered Rect of `pct_x`% × `pct_y`% of `area`.
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
    let width = area.width.saturating_mul(pct_x) / 100;
    let height = area.height.saturating_mul(pct_y) / 100;
    let x = area.x + (area.width.saturating_sub(width)) / 2;
    let y = area.y + (area.height.saturating_sub(height)) / 2;
    Rect {
        x,
        y,
        width,
        height,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn status_line_normal_mode_no_name() {
        let s = format_status_line("NORMAL", "[No Name]", false, 0, 0, 1, 60);
        assert!(s.contains("NORMAL"));
        assert!(s.contains("[No Name]"));
        assert!(s.contains("1:1"));
        assert!(s.contains("100%"));
    }

    #[test]
    fn status_line_dirty_marker() {
        let clean = format_status_line("NORMAL", "foo.txt", false, 0, 0, 1, 60);
        let dirty = format_status_line("NORMAL", "foo.txt", true, 0, 0, 1, 60);
        assert!(clean.contains(" [No Name]") || clean.contains(" foo.txt"));
        // dirty marker is `*` in dirty, ` ` in clean
        let dirty_idx = dirty.find('*');
        assert!(dirty_idx.is_some(), "dirty status should contain '*'");
        let clean_contains_star = clean.contains('*');
        assert!(!clean_contains_star, "clean status should not contain '*'");
    }

    #[test]
    fn status_line_percentage() {
        let s = format_status_line("NORMAL", "f.txt", false, 4, 0, 10, 60);
        // row 4 of 10 = 50%
        assert!(s.contains("50%"));
    }

    #[test]
    fn status_line_fits_width() {
        let width: u16 = 40;
        let s = format_status_line("INSERT", "myfile.rs", true, 0, 0, 100, width);
        assert_eq!(s.len(), width as usize);
    }

    #[test]
    fn write_message_format() {
        let msg = format_write_message("/tmp/foo.txt", 10, 128);
        assert_eq!(msg, "\"/tmp/foo.txt\" 10L, 128B written");
    }

    #[test]
    fn status_line_readonly_tag() {
        let s = format_status_line_full("NORMAL", "foo.txt", false, true, false, 0, 0, 1, 80);
        assert!(s.contains("[RO]"), "readonly tag must appear");
    }

    #[test]
    fn status_line_new_file_tag() {
        let s = format_status_line_full("NORMAL", "newfile.txt", false, false, true, 0, 0, 1, 80);
        assert!(s.contains("[New File]"), "new-file tag must appear");
    }

    #[test]
    fn status_line_truncates_long_filename() {
        // Very narrow terminal — filename must be truncated.
        let long = "some/very/long/path/to/a/deeply/nested/file.rs";
        let s = format_status_line_full("NORMAL", long, false, false, false, 0, 0, 1, 30);
        // Truncated filename starts with `…`
        assert!(
            s.contains('\u{2026}'),
            "truncated filename must start with …"
        );
    }

    /// Truncating a filename that contains multibyte chars must not panic.
    ///
    /// Before the fix, `format_status_line_full` computed
    /// `start = filename.len().saturating_sub(keep)` and sliced `&filename[start..]`.
    /// `keep` is a display-width value, not a byte count; on a filename like
    /// "éàü.rs" (each accent = 2 UTF-8 bytes), `filename.len() - keep` can land
    /// inside a multibyte char, causing a panic "byte index N is not a char boundary".
    #[test]
    fn status_line_truncates_multibyte_filename_no_panic() {
        // 6 accented chars (2 bytes each) + ".rs" = 15 bytes, 9 chars.
        // Force truncation with a narrow width so keep < byte_len.
        let name = "éàüöïâ.rs";
        // width=30 will likely force truncation; the exact geometry doesn't
        // matter — we just need it to not panic and produce valid UTF-8.
        let s = format_status_line_full("NORMAL", name, false, false, false, 0, 0, 1, 30);
        // Result must be valid UTF-8 (String guarantees this on success).
        // Also verify the ellipsis is present when the name was truncated.
        let _ = s; // no panic = success
    }

    #[test]
    fn status_line_arg_parsing_plus_n() {
        // Smoke test: +5 → goto_line=Some(5). Tested via parse logic in main.
        // Here we verify the status-line can handle being on line 5.
        let s = format_status_line("NORMAL", "file.txt", false, 4, 0, 10, 60);
        // row 4 (0-based) → 5 in display
        assert!(s.contains("5:1"));
    }

    #[test]
    fn top_bar_height_is_one() {
        assert_eq!(crate::app::TOP_BAR_HEIGHT, 1);
    }

    /// #303: `transparent = false` (the default) paints the editor text area +
    /// gutter with the theme's editor background; `transparent = true` clears
    /// the fill so the terminal's own background shows through.
    #[test]
    fn buffer_background_honours_transparent_flag() {
        use crate::app::App;
        use ratatui::style::{Color, Style};

        // Opaque (default): the base background carries the theme's editor bg.
        let mut cfg = hjkl_app::config::Config::default();
        cfg.theme.transparent = false;
        let app = App::new(None, false, None, None)
            .unwrap()
            .with_config(cfg.clone());
        assert!(!app.theme_transparent);
        assert_eq!(
            buffer_background_style(&app),
            Style::default().bg(app.theme.ui.background),
            "opaque config must fill with the theme background"
        );
        // Sanity: a bg is actually set (not Reset).
        assert!(matches!(
            buffer_background_style(&app).bg,
            Some(Color::Rgb(_, _, _))
        ));

        // Transparent: no fill — BufferView.background is the empty default.
        cfg.theme.transparent = true;
        let app_t = App::new(None, false, None, None).unwrap().with_config(cfg);
        assert!(app_t.theme_transparent);
        assert_eq!(
            buffer_background_style(&app_t),
            Style::default(),
            "transparent config must leave the background unpainted (terminal shows through)"
        );
        assert_eq!(buffer_background_style(&app_t).bg, None);
    }

    /// A file's name in the explorer must be colored uniformly — every cell of
    /// the name (including the LAST one) gets the filetype color, not a stray
    /// default-white cell at the end.
    #[test]
    fn explorer_file_name_coloring_is_uniform() {
        use crate::app::App;
        use crate::keymap_actions::AppAction;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(tmp.path().join("a").join("b").join("c")).unwrap();
        std::fs::write(
            tmp.path().join("a").join("b").join("c").join("widget.rs"),
            b"x",
        )
        .unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        // Open the DEEP file so there's no splash and its ancestors are expanded
        // (the white-last-char artifact only shows on nested rows).
        let mut app = App::new(Some("a/b/c/widget.rs".into()), false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Draw twice: the first frame settles window rects/viewports.
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        let text_color = app.theme.ui.text;

        // Locate the contiguous run of cells spelling "widget.rs".
        let name: Vec<char> = "widget.rs".chars().collect();
        let n = name.len() as u16;
        let cell_sym = |x: u16, y: u16| -> String {
            buf.cell((x, y))
                .map(|c| c.symbol().to_string())
                .unwrap_or_default()
        };
        let mut found: Option<(u16, u16)> = None;
        'scan: for y in 0..24u16 {
            for x in 0..(80 - n) {
                if (0..n).all(|i| cell_sym(x + i, y) == name[i as usize].to_string()) {
                    found = Some((x, y));
                    break 'scan;
                }
            }
        }
        let (sx, sy) = found.expect("widget.rs must be rendered in the explorer");
        let _ = text_color;

        // The icon (two cells before the name) carries the filetype color; the
        // whole name must share that exact color — no `Reset`/white cells.
        let icon_fg = buf.cell((sx - 2, sy)).unwrap().fg;
        assert_ne!(icon_fg, Color::Reset, "icon should be filetype-colored");
        let fgs: Vec<Color> = (0..n).map(|i| buf.cell((sx + i, sy)).unwrap().fg).collect();
        assert!(
            fgs.iter().all(|&f| f == icon_fg),
            "every cell of the name must share the filetype color (no uncolored/white \
             cells); icon_fg={icon_fg:?} name fgs={fgs:?}"
        );
    }

    /// The explorer's text-derived render data is cached against the buffer's
    /// `dirty_gen` (P7). A reconcile that ADDS an entry must invalidate it: the
    /// new row has to get its overlay glyph/color (from `overlay_nodes`) and its
    /// `<US><id>` tail has to be concealed (from `conceals`). A stale cache
    /// shows up as an unstyled, un-concealed row.
    #[test]
    fn explorer_render_cache_invalidates_on_reconcile() {
        use crate::app::App;
        use crate::keymap_actions::AppAction;
        use hjkl_engine::BufferEdit;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("existing.txt"), "hi").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = App::new(Some("existing.txt".into()), false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Two frames: window rects settle AND the cache gets populated for the
        // pre-reconcile buffer generation.
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();

        let find_run = |buf: &ratatui::buffer::Buffer, needle: &str| -> Option<(u16, u16)> {
            let chars: Vec<String> = needle.chars().map(|c| c.to_string()).collect();
            let n = chars.len() as u16;
            // Skip row 0: the tab/buffer bar also spells out file names.
            for y in 1..24u16 {
                for x in 0..(80 - n) {
                    if (0..n).all(|i| {
                        buf.cell((x + i, y)).map(|c| c.symbol()) == Some(chars[i as usize].as_str())
                    }) {
                        return Some((x, y));
                    }
                }
            }
            None
        };

        assert!(
            find_run(terminal.backend().buffer(), "newfile.rs").is_none(),
            "newfile.rs must not exist before the reconcile"
        );

        // Add a root-depth entry and reconcile — this creates the file on disk
        // and rewrites the explorer buffer (new `dirty_gen`).
        let idx = app
            .slots()
            .iter()
            .position(|s| s.is_explorer())
            .expect("explorer slot");
        let cur = app.slots()[idx].buffer().as_string();
        let newtext = format!("{cur}\n  newfile.rs");
        BufferEdit::replace_all(app.slots_mut()[idx].buffer_mut(), &newtext);
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        let (sx, sy) = find_run(&buf, "newfile.rs")
            .expect("the reconciled entry must render (stale cached tree otherwise)");

        // Overlay data rebuilt: the icon two cells left of the name is painted
        // and carries a definite fg. A stale `overlay_nodes` has no slot for
        // this row, so the overlay loop skips it entirely.
        let icon = buf.cell((sx - 2, sy)).expect("icon cell");
        assert_ne!(
            icon.symbol().trim(),
            "",
            "new row must get its overlay icon glyph"
        );
        assert_ne!(
            icon.fg,
            Color::Reset,
            "new row's icon must get its filetype color"
        );

        // Conceals rebuilt: the `<US><id>` tail after the name is hidden.
        let n = "newfile.rs".chars().count() as u16;
        for i in 0..4u16 {
            let sym = buf
                .cell((sx + n + i, sy))
                .map(|c| c.symbol().to_string())
                .unwrap_or_default();
            assert!(
                sym.trim().is_empty(),
                "the id tail after the new entry must stay concealed, saw {sym:?} at +{i}"
            );
        }
    }

    /// `:diffsplit` paints DiffChange line bands and DiffText char highlights
    /// (#208 Phase 2). One line differs; its changed characters get the stronger
    /// DiffText bg and the rest of the line gets the DiffChange band bg.
    #[test]
    fn diffsplit_paints_change_band_and_difftext() {
        use crate::app::App;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let a = tmp.path().join("a.txt");
        let b = tmp.path().join("b.txt");
        std::fs::write(&a, "alpha\nbeta\ngamma\n").unwrap();
        std::fs::write(&b, "alpha\nBETA\ngamma\n").unwrap();

        let mut app = App::new(Some(a), false, None, None).unwrap();
        app.dispatch_ex(&format!("diffsplit {}", b.display()));

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Draw twice so window rects/viewports settle before the overlay pass.
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        let change_bg = Color::Rgb(32, 42, 60);
        let text_bg = Color::Rgb(48, 78, 110);

        let mut has_change = false;
        let mut has_text = false;
        for y in 0..24u16 {
            for x in 0..80u16 {
                if let Some(c) = buf.cell((x, y)) {
                    if c.bg == change_bg {
                        has_change = true;
                    }
                    if c.bg == text_bg {
                        has_text = true;
                    }
                }
            }
        }
        assert!(has_change, "a DiffChange band must be painted");
        assert!(has_text, "a DiffText char highlight must be painted");
    }

    /// Filler rows (#250) keep both diff windows aligned: a line only present in
    /// one buffer pushes a blank `DiffDelete`-tinted filler into the other, so a
    /// shared line below the insertion lands on the SAME screen row in both
    /// windows.
    #[test]
    fn diffsplit_filler_aligns_shared_line() {
        use crate::app::App;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let a = tmp.path().join("a.txt");
        let b = tmp.path().join("b.txt");
        // `ins` exists only in b → a gets a filler row before the later lines.
        // The shared marker `zebra` sits well below the transient top-right
        // toast so the alignment check isn't occluded by it.
        std::fs::write(&a, "l0\nl1\nl2\nl3\nl4\nl5\nzebra\n").unwrap();
        std::fs::write(&b, "l0\nl1\nins\nl2\nl3\nl4\nl5\nzebra\n").unwrap();

        let mut app = App::new(Some(a), false, None, None).unwrap();
        app.dispatch_ex(&format!("diffsplit {}", b.display()));

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        let cell_sym = |x: u16, y: u16| -> String {
            buf.cell((x, y))
                .map(|c| c.symbol().to_string())
                .unwrap_or_default()
        };
        // Find both occurrences of the shared marker (one per window).
        let needle: Vec<char> = "zebra".chars().collect();
        let n = needle.len() as u16;
        let mut gamma_rows = Vec::new();
        for y in 0..24u16 {
            for x in 0..(80 - n) {
                if (0..n).all(|i| cell_sym(x + i, y) == needle[i as usize].to_string()) {
                    gamma_rows.push(y);
                }
            }
        }
        assert_eq!(
            gamma_rows.len(),
            2,
            "gamma must render once per diff window; got {gamma_rows:?}"
        );
        assert_eq!(
            gamma_rows[0], gamma_rows[1],
            "filler must align `gamma` on the same screen row in both windows"
        );

        // A DiffDelete filler row must be painted (muted dark red).
        let filler_bg = Color::Rgb(60, 32, 32);
        let has_filler = (0..24u16)
            .any(|y| (0..80u16).any(|x| buf.cell((x, y)).map(|c| c.bg) == Some(filler_bg)));
        assert!(has_filler, "a DiffDelete filler row must be painted");
    }

    // ── Audit D2: diagnostics clipped to the visible viewport ──────────────

    /// `row_in_viewport` is the shared predicate `build_diag_overlays` and the
    /// eol-hint builder use to skip off-screen diagnostics. `vp_top` is
    /// inclusive, `vp_bot` is exclusive — matches the `top_row..top_row+height`
    /// convention used everywhere else in this file (e.g. `visible_signs`).
    #[test]
    fn row_in_viewport_bounds() {
        assert!(!row_in_viewport(4, 5, 10), "row before vp_top is out");
        assert!(row_in_viewport(5, 5, 10), "vp_top is inclusive");
        assert!(row_in_viewport(9, 5, 10), "last visible row is inclusive");
        assert!(!row_in_viewport(10, 5, 10), "vp_bot is exclusive");
        assert!(!row_in_viewport(500, 5, 10), "far off-screen row is out");
    }

    /// The viewport clip must live in the shared `visible_diags` precompute:
    /// only a diagnostic whose `start_row` is inside `[vp_top, vp_bot)` may
    /// reach `build_diag_overlays` at all. This is the direct regression
    /// guard for the audit D2 allocation-churn bug — a diagnostic thousands
    /// of lines off-screen must neither appear in the output nor be touched
    /// by the overlay `.map()`.
    #[test]
    fn build_diag_overlays_clips_to_viewport() {
        use crate::app::{App, LspDiag};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("diag_clip.txt");
        let content: String = (0..500).map(|i| format!("line {i}\n")).collect();
        std::fs::write(&path, &content).unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        let mk = |row: usize, msg: &str| LspDiag {
            start_row: row,
            start_col: 0,
            end_row: row,
            end_col: 3,
            severity: DiagSeverity::Error,
            message: msg.to_string(),
            source: None,
            code: None,
        };
        app.slots_mut()[0].lsp_diags = vec![
            mk(0, "top edge, in range"),
            mk(5, "middle, in range"),
            mk(49, "bottom edge, in range (vp_bot exclusive at 50)"),
            mk(50, "just past vp_bot, out"),
            mk(400, "far off-screen, out"),
        ];
        let clip = |vp_top: usize, vp_bot: usize| {
            let visible: Vec<&LspDiag> = app.slots()[0]
                .lsp_diags
                .iter()
                .filter(|d| row_in_viewport(d.start_row, vp_top, vp_bot))
                .collect();
            build_diag_overlays(&visible, &app.theme.ui)
        };

        let overlays = clip(0, 50);
        let mut rows: Vec<usize> = overlays.iter().map(|o| o.row).collect();
        rows.sort_unstable();
        assert_eq!(
            rows,
            vec![0, 5, 49],
            "only diagnostics with start_row in [0, 50) may produce an overlay"
        );

        // Scrolling the viewport down must reveal the previously off-screen
        // diagnostic and hide the ones that scrolled out — proves the clip is
        // a real viewport window, not a fixed cutoff.
        let overlays2 = clip(400, 450);
        let rows2: Vec<usize> = overlays2.iter().map(|o| o.row).collect();
        assert_eq!(rows2, vec![400]);
    }

    /// End-to-end regression guard: a diagnostic on a visible row must still
    /// render its EOL ghost-text hint exactly as before the viewport clip —
    /// the clip must be invisible for on-screen rows. `diagnostics_inline`
    /// defaults to `all`, so no config is needed to enable the hint.
    #[test]
    fn visible_diagnostic_eol_hint_still_renders() {
        use crate::app::{App, LspDiag};
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("diag_eol.rs");
        // Plenty of trailing lines so a diagnostic near the end is off-screen
        // while the cursor (row 0) stays on-screen.
        let content: String = std::iter::once("let x = 1;\n".to_string())
            .chain((0..500).map(|i| format!("// filler {i}\n")))
            .collect();
        std::fs::write(&path, &content).unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        app.slots_mut()[0].lsp_diags = vec![
            LspDiag {
                start_row: 0,
                start_col: 0,
                end_row: 0,
                end_col: 1,
                severity: DiagSeverity::Error,
                message: "visible unused variable".to_string(),
                source: None,
                code: None,
            },
            LspDiag {
                start_row: 450,
                start_col: 0,
                end_row: 450,
                end_col: 1,
                severity: DiagSeverity::Error,
                message: "offscreen diagnostic".to_string(),
                source: None,
                code: None,
            },
        ];

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        let mut rendered = String::new();
        for y in 0..24u16 {
            for x in 0..80u16 {
                if let Some(c) = buf.cell((x, y)) {
                    rendered.push_str(c.symbol());
                }
            }
        }
        assert!(
            rendered.contains("visible unused variable"),
            "the cursor-row diagnostic's EOL hint must still render: {rendered:?}"
        );
        assert!(
            !rendered.contains("offscreen diagnostic"),
            "the far off-screen diagnostic must never reach the screen buffer"
        );
    }

    /// End-to-end regression guard for the shared `visible_diags` precompute:
    /// a diagnostic on a visible row must still paint its underline overlay
    /// at exactly the diagnostic's row/col, AND its EOL hint must render —
    /// both consumers read the same pre-filtered list, so a wrong filter (or
    /// a consumer that regressed to re-scanning) shows up here as a missing
    /// or misplaced overlay.
    #[test]
    fn diag_overlay_underline_lands_on_the_diag_span_after_precompute() {
        use crate::app::{App, LspDiag};
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("diag_overlay.rs");
        // Row 0 is the only line: the diagnostic span covers chars 4..7
        // ("x = 1" → `x = 1;`), and its message must render as an EOL hint.
        std::fs::write(&path, "let x = 1;\n").unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        app.slots_mut()[0].lsp_diags = vec![LspDiag {
            start_row: 0,
            start_col: 4,
            end_row: 0,
            end_col: 7,
            severity: DiagSeverity::Error,
            message: "overlay anchor".to_string(),
            source: None,
            code: None,
        }];

        let backend = TestBackend::new(80, 10);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        // Locate the 'x' of "let x = 1;" — the first cell past the gutter.
        let row0: Vec<(u16, &ratatui::buffer::Cell)> = (0..80u16)
            .filter_map(|x| buf.cell((x, 0)).map(|c| (x, c)))
            .collect();
        let x_col = row0
            .iter()
            .find(|(_, c)| c.symbol() == "x")
            .unwrap_or_else(|| panic!("row 0 must contain the 'x' of 'let x = 1;'"))
            .0;
        // The underline overlay covers doc cols 4..7 → the three cells at
        // x_col..x_col+3 ("x = 1" is 5 chars: 'x',' ','=',' ','1' — the
        // span is cols 4..7, which are 'x',' ','=' ... wait, 0-based: the
        // span covers chars 4,5,6 of "let x = 1;"). Assert exactly those
        // cells carry the UNDERLINED modifier and their neighbours do not.
        for (i, expected) in [(0, true), (1, true), (2, true), (3, false)] {
            let c = buf
                .cell((x_col + i, 0))
                .unwrap_or_else(|| panic!("cell ({}, 0) exists", x_col + i));
            assert_eq!(
                c.modifier.contains(Modifier::UNDERLINED),
                expected,
                "diag underline must cover exactly the span cells (offset {i} from 'x')"
            );
        }
        // The EOL hint for the same diagnostic must still render (it reads
        // the same pre-filtered list).
        let mut rendered = String::new();
        for y in 0..10u16 {
            for x in 0..80u16 {
                if let Some(c) = buf.cell((x, y)) {
                    rendered.push_str(c.symbol());
                }
            }
        }
        assert!(
            rendered.contains("overlay anchor"),
            "the diagnostic's EOL hint must render: {rendered:?}"
        );
    }

    /// End-to-end: a markdown fenced code block's tint must cover whole
    /// lines, and the cursorline must show through it on the cursor's row.
    /// Exercises the real chain — markdown grammar → `hjkl_syntax` span table
    /// → `BufferView::paint_row` — which the unit tests either side of it
    /// only cover with synthetic spans.
    #[test]
    #[ignore = "network + compiler: fetches the markdown grammar"]
    fn markdown_code_block_tint_covers_whole_lines_and_yields_to_cursorline() {
        use crate::app::App;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("block.md");
        // Cursor starts on row 0 ("# Title"), so the block rows below it are
        // all non-cursor rows; row 3 ("ls -la") is the one we then move to.
        std::fs::write(&path, "# Title\n\n```bash\nls -la\n\necho hi\n```\n").unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Draw until the grammar has loaded and spans are installed (the
        // first frames settle window rects and kick off the parse).
        let mut buf = None;
        for _ in 0..200 {
            app.poll_grammar_loads();
            app.recompute_and_install();
            terminal.draw(|f| frame(f, &mut app)).unwrap();
            let b = terminal.backend().buffer().clone();
            // Settled when the code-block row's TEXT is tinted differently
            // from a row outside the block. Comparing trailing cells instead
            // would also match a half-painted first frame.
            let block_text = b.cell((6, 3)).map(|c| c.bg);
            if block_text != b.cell((6, 0)).map(|c| c.bg) {
                buf = Some(b);
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
        let buf = buf.expect("markdown code-block spans never reached the screen buffer");

        // Text starts after the number gutter; find it from the block's own
        // line so the assertions never straddle a gutter cell.
        let text_x = (0u16..80)
            .find(|&x| buf.cell((x, 3)).unwrap().symbol() == "l")
            .expect("row 3 renders `ls -la`");
        // Rows 2..=6 are the block (both fences included). Every cell on each
        // of them — text, trailing, and the blank row inside the block —
        // carries the same tint, and it is not the plain editor bg.
        let plain_bg = buf.cell((79, 0)).unwrap().bg;
        let block_bg = buf.cell((79, 3)).unwrap().bg;
        assert_ne!(block_bg, plain_bg, "code block must be tinted");
        for y in 2u16..=6 {
            for x in text_x..80 {
                assert_eq!(
                    buf.cell((x, y)).unwrap().bg,
                    block_bg,
                    "row {y} col {x}: block tint must cover the whole line"
                );
            }
        }

        // Move the cursor onto the OPENING FENCE row: the cursorline owns it
        // whole, including the ``` characters (markdown captures those as
        // `@markup.raw.block` a second time, as their own narrow span).
        app.dispatch_ex("3");
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let fence_buf = terminal.backend().buffer().clone();
        let fence_bg = fence_buf.cell((79, 2)).unwrap().bg;
        assert_ne!(fence_bg, block_bg, "cursorline must own the fence row too");
        for x in (text_x + 1)..80 {
            assert_eq!(
                fence_buf.cell((x, 2)).unwrap().bg,
                fence_bg,
                "col {x}: the ``` cells must yield to the cursorline"
            );
        }

        // Move the cursor onto a block row: the cursorline now owns it.
        app.dispatch_ex("4");
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        let cursor_bg = buf.cell((79, 3)).unwrap().bg;
        assert_ne!(
            cursor_bg, block_bg,
            "cursorline bg must override the block tint"
        );
        // From `text_x + 1`: the first text cell holds the cursor itself,
        // painted reversed.
        for x in (text_x + 1)..80 {
            let cell = buf.cell((x, 3)).unwrap();
            assert_eq!(
                cell.bg, cursor_bg,
                "col {x}: the whole cursor row carries the cursorline bg"
            );
        }
        // Neighbouring block rows keep the tint.
        assert_eq!(buf.cell((79, 2)).unwrap().bg, block_bg);
        assert_eq!(buf.cell((79, 4)).unwrap().bg, block_bg);
    }

    /// The matchparen partner must not look like a second cursor: the cursor
    /// cell keeps its reversed block, the partner gets the muted matchparen
    /// bg with no `REVERSED`. Before, both cells were painted
    /// `BOLD | REVERSED` and `%` jumped between two identical blocks.
    #[test]
    fn matchparen_partner_is_muted_next_to_the_cursor_block() {
        use crate::app::App;
        use hjkl_buffer::Position;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("pair.txt");
        std::fs::write(&path, "foo(bar)baz\n").unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        // Cursor on the `(` at col 3; its partner is the `)` at col 7.
        app.active_editor_mut()
            .buffer_mut()
            .set_cursor(Position::new(0, 3));
        app.sync_after_engine_mutation();
        assert_eq!(
            app.matchparen_cells(),
            Some([(0, 3), (0, 7)]),
            "precondition: the pair must resolve, or this test asserts nothing"
        );

        let backend = TestBackend::new(60, 8);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        // Locate both cells by their glyphs on the buffer's first screen row.
        let col_of = |ch: &str| {
            (0..60u16)
                .find(|&x| buf.cell((x, 0)).unwrap().symbol() == ch)
                .unwrap_or_else(|| panic!("no `{ch}` on row 0"))
        };
        let open = buf.cell((col_of("("), 0)).unwrap();
        let close = buf.cell((col_of(")"), 0)).unwrap();

        assert!(
            open.modifier.contains(Modifier::REVERSED),
            "the cursor's own cell keeps the reversed cursor block"
        );
        assert!(
            !close.modifier.contains(Modifier::REVERSED),
            "the partner must NOT be reversed — that is what made it read as a \
             second cursor"
        );
        assert_eq!(
            close.bg, app.theme.ui.match_paren_bg,
            "the partner carries the muted matchparen bg"
        );
        assert!(
            close.modifier.contains(Modifier::BOLD),
            "the partner stays bold so it is still easy to spot"
        );
    }

    /// `:set nofoldenable` shows every line again while the folds keep their
    /// closed state, so `:set foldenable` puts them back exactly as they were.
    /// The render path read the fold list directly, so `nofoldenable` changed
    /// nothing on screen — the only way out of a fold was to open it.
    #[test]
    fn nofoldenable_renders_folded_lines_without_discarding_the_fold() {
        use crate::app::App;
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("fold.txt");
        std::fs::write(&path, "region {{{\nhidden body\nend }}}\ntail\n").unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        app.dispatch_ex("set foldmethod=marker foldlevelstart=0");
        app.recompute_and_install();
        let folds = app.active_editor().buffer().folds();
        assert_eq!(folds.len(), 1, "precondition: one marker fold");
        assert!(folds[0].closed, "precondition: it starts closed");

        let backend = TestBackend::new(40, 8);
        let mut terminal = Terminal::new(backend).unwrap();
        let screen = |t: &Terminal<TestBackend>| {
            let b = t.backend().buffer().clone();
            (0..8u16)
                .map(|y| {
                    (0..40u16)
                        .map(|x| b.cell((x, y)).unwrap().symbol().to_string())
                        .collect::<String>()
                })
                .collect::<Vec<_>>()
                .join("\n")
        };
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        assert!(
            !screen(&terminal).contains("hidden body"),
            "precondition: the closed fold hides its body"
        );

        app.dispatch_ex("set nofoldenable");
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        assert!(
            screen(&terminal).contains("hidden body"),
            "nofoldenable must show the folded lines"
        );
        assert!(
            app.active_editor().buffer().folds()[0].closed,
            "the fold itself stays closed, so `set foldenable` restores it"
        );

        app.dispatch_ex("set foldenable");
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        assert!(
            !screen(&terminal).contains("hidden body"),
            "re-enabling folds hides the body again"
        );
    }

    /// Render `app` at 60x12 and return the drawn buffer.
    fn draw_frame(app: &mut App, w: u16, h: u16) -> ratatui::buffer::Buffer {
        use ratatui::{Terminal, backend::TestBackend};
        let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
        // Twice: the first frame settles window rects / viewports.
        terminal.draw(|f| frame(f, app)).unwrap();
        terminal.draw(|f| frame(f, app)).unwrap();
        terminal.backend().buffer().clone()
    }

    /// Cells left on the TERMINAL's own background — the holes a theme is
    /// supposed to cover.
    fn reset_bg_cells(buf: &ratatui::buffer::Buffer, w: u16, h: u16) -> usize {
        (0..h)
            .flat_map(|y| (0..w).map(move |x| (x, y)))
            .filter(|&(x, y)| buf.cell((x, y)).is_some_and(|c| c.bg == Color::Reset))
            .count()
    }

    fn themed_app(path: Option<std::path::PathBuf>) -> App {
        let mut app = App::new(path, false, None, None).unwrap();
        // A scheme whose background is nothing like the terminal default, so a
        // missed region shows up as `Color::Reset` rather than a lucky match.
        assert!(app.apply_named_theme("dracula"), "dracula must be bundled");
        app
    }

    /// The theme's background belongs to the WHOLE terminal, not just the
    /// editor pane. The splash screen is the clearest case: it paints no
    /// background of its own, so every cell used to be the terminal's colour
    /// and the theme visibly stopped at the editor.
    #[test]
    fn splash_screen_is_painted_on_the_theme_background() {
        let mut app = themed_app(None);
        let buf = draw_frame(&mut app, 60, 12);
        assert_eq!(
            reset_bg_cells(&buf, 60, 12),
            0,
            "no cell may fall back to the terminal background"
        );
        assert_eq!(
            buf.cell((0, 1)).unwrap().bg,
            app.theme.ui.background,
            "the splash area carries the theme background"
        );
    }

    /// Same for every layout that mixes chrome with buffers — tabline row,
    /// explorer pane, split separator, quickfix dock, floating overlays.
    #[test]
    fn no_layout_leaves_terminal_background_showing() {
        use crate::keymap_actions::AppAction;
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("bg.txt");
        std::fs::write(&path, "hello\nworld\n").unwrap();

        let mut with_explorer = themed_app(Some(path.clone()));
        with_explorer.dispatch_action(AppAction::ToggleExplorer, 1);

        let mut with_split = themed_app(Some(path.clone()));
        with_split.dispatch_ex("vsp");

        let mut with_tabs = themed_app(Some(path.clone()));
        with_tabs.dispatch_ex("tabnew");

        let mut with_dock = themed_app(Some(path.clone()));
        with_dock.dispatch_ex("copen");

        let mut with_picker = themed_app(Some(path.clone()));
        with_picker.dispatch_ex("Files");

        let mut with_cmdline = themed_app(Some(path));
        with_cmdline.open_command_prompt();

        for (name, app) in [
            ("explorer", &mut with_explorer),
            ("vsplit", &mut with_split),
            ("tabs", &mut with_tabs),
            ("quickfix", &mut with_dock),
            ("picker", &mut with_picker),
            ("cmdline", &mut with_cmdline),
        ] {
            let buf = draw_frame(app, 60, 12);
            assert_eq!(
                reset_bg_cells(&buf, 60, 12),
                0,
                "{name}: cells still on the terminal background"
            );
        }
    }

    /// The split separator resets its cell before drawing the glyph (so no
    /// wide character bleeds through), which also dropped the frame's base
    /// background — it has to paint it back.
    #[test]
    fn split_separator_cell_carries_the_theme_background() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("sep.txt");
        std::fs::write(&path, "hello\n").unwrap();
        let mut app = themed_app(Some(path));
        app.dispatch_ex("vsp");
        let buf = draw_frame(&mut app, 60, 12);

        let sep = (0..60u16)
            .find(|&x| buf.cell((x, 0)).is_some_and(|c| c.symbol() == "│"))
            .expect("a vertical split draws a `│` separator on row 0");
        assert_eq!(
            buf.cell((sep, 0)).unwrap().bg,
            app.theme.ui.background,
            "the separator column must be on the theme background"
        );
    }

    /// Toasts clear their rect before drawing, so they need an explicit
    /// background or they punch a terminal-coloured hole in the theme.
    #[test]
    fn toast_popup_is_painted_on_the_panel_background() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("toast.txt");
        std::fs::write(&path, "hello\n").unwrap();
        let mut app = themed_app(Some(path));
        // `:colorscheme` reports what it applied — a real toast from a real
        // command, rather than a hand-pushed one that could drift from how
        // the app actually notifies.
        app.dispatch_ex("colorscheme dracula");
        let buf = draw_frame(&mut app, 60, 12);

        // Find the toast's top-left border corner.
        let corner = (0..12u16)
            .flat_map(|y| (0..60u16).map(move |x| (x, y)))
            .find(|&(x, y)| buf.cell((x, y)).is_some_and(|c| c.symbol() == "┌"))
            .expect("an active toast draws a bordered box");
        assert_eq!(
            buf.cell(corner).unwrap().bg,
            app.theme.ui.panel_bg,
            "the toast border sits on the panel background"
        );
        // A cell inside the box too, not just the frame.
        let inside = (corner.0 + 1, corner.1 + 1);
        assert_eq!(
            buf.cell(inside).unwrap().bg,
            app.theme.ui.panel_bg,
            "the toast interior is filled, not left transparent"
        );
    }

    /// `theme.transparent` keeps the terminal's own background — the frame
    /// fill must not defeat it.
    #[test]
    fn transparent_theme_still_shows_the_terminal_background() {
        let mut cfg = hjkl_app::config::Config::default();
        cfg.theme.transparent = true;
        let mut app = App::new(None, false, None, None).unwrap().with_config(cfg);
        assert!(app.theme_transparent, "precondition: transparency is on");
        let buf = draw_frame(&mut app, 60, 12);
        assert!(
            reset_bg_cells(&buf, 60, 12) > 400,
            "most of the screen must still be the terminal background"
        );
    }

    /// The start screen is a title card: it takes the whole terminal. The
    /// status line used to be drawn underneath it, so the splash read as a
    /// half-drawn editor with a mode badge under the art.
    #[test]
    fn start_screen_covers_the_whole_terminal() {
        let mut app = themed_app(None);
        assert!(app.start_screen.is_some(), "precondition: splash is up");
        let buf = draw_frame(&mut app, 60, 12);

        let screen: String = (0..12u16)
            .map(|y| {
                (0..60u16)
                    .map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            !screen.contains("NORMAL") && !screen.contains("START"),
            "no mode badge may show while the splash is up:\n{screen}"
        );
        // The last row belongs to the splash too — nothing painted a status
        // line there.
        let last_row: String = (0..60u16)
            .map(|x| buf.cell((x, 11)).unwrap().symbol().to_string())
            .collect();
        assert!(
            last_row.trim().is_empty(),
            "the bottom row must be part of the splash, got {last_row:?}"
        );
    }

    /// Opening a file means no splash at all, so the status line is back.
    #[test]
    fn status_line_returns_once_the_splash_is_gone() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("s.txt");
        std::fs::write(&path, "hello\n").unwrap();
        let mut app = themed_app(Some(path));
        assert!(app.start_screen.is_none(), "precondition: no splash");
        let buf = draw_frame(&mut app, 60, 12);
        let last_row: String = (0..60u16)
            .map(|x| buf.cell((x, 11)).unwrap().symbol().to_string())
            .collect();
        assert!(
            last_row.contains("NORMAL"),
            "the status line must be drawn without a splash, got {last_row:?}"
        );
    }

    // ── Fix 2: screen_rect derived from the focused window's viewport ──────

    /// `App::screen_rect()` must return the real terminal size even with a
    /// split open. Before the fix it derived from the FOCUSED window's
    /// viewport — but this render loop sets each window's viewport to its
    /// PANE dims (`render_window`, `vp.height = area.height` above), so
    /// with a horizontal split the old `screen_rect()` returned the
    /// focused pane's height, not the terminal's. That broke the
    /// status-line hit-test (mouse.rs) and context-menu clamping/hover
    /// (event_loop.rs): right-clicking the real bottom row resolved to a
    /// mid-screen zone instead of `Zone::StatusLine`.
    #[test]
    fn screen_rect_is_real_terminal_rect_with_split() {
        use crate::app::App;
        use crate::app::mouse::{Zone, hit_test_zone};
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("split_screen_rect.txt");
        std::fs::write(&path, "l0\nl1\nl2\nl3\nl4\n").unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();
        // Horizontal split: the focused window's pane is roughly half the
        // terminal height, so a bug in `screen_rect()` shows up as a wrong
        // height rather than accidentally matching by coincidence.
        app.dispatch_ex("sp");

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Draw twice: the first frame settles window rects/viewports (same
        // pattern as the other TestBackend tests in this file).
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();

        let screen = app.screen_rect();
        assert_eq!(
            (screen.width, screen.height),
            (80, 24),
            "screen_rect() must report the real 80x24 terminal, not the \
             focused pane's shrunken dims"
        );

        // The real bottom row (23, 0-based on a 24-row terminal) must
        // hit-test as the status line. With the pre-fix `screen_rect()`
        // undercounting the height, this row is ABOVE the (wrongly
        // computed) status row, so it fell through to whatever window pane
        // occupies row 23 instead.
        let zone = hit_test_zone(&app, 10, 23);
        assert_eq!(
            zone,
            Zone::StatusLine,
            "row 23 (the real bottom row) must hit-test as the status line; got {zone:?}"
        );
    }

    // ── Fix 5: completion popup anchored to the stale slot-editor viewport ──

    /// `completion_popup` used to read `app.slots()[slot_idx].editor.host().viewport()`
    /// — the SLOT bridge editor's viewport, which stays pinned at its
    /// creation `top_row` (#151: per-window editors own scroll, not the
    /// slot bridge). Scroll a window deep, open a completion popup anchored
    /// near the new (scrolled) cursor row, and check the popup actually
    /// renders near that row instead of computing a huge/garbage row from
    /// the stale `top_row=0` and vanishing off the 24-row screen entirely.
    #[test]
    fn completion_popup_anchors_to_scrolled_window_not_stale_slot_viewport() {
        use crate::app::App;
        use crate::completion::{Completion, CompletionItem};
        use ratatui::{Terminal, backend::TestBackend};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("completion_scroll.txt");
        let content: String = (0..100).map(|i| format!("line{i}\n")).collect();
        std::fs::write(&path, &content).unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // First frame settles window rects/viewports/text_width.
        terminal.draw(|f| frame(f, &mut app)).unwrap();

        // Scroll the WINDOW's own editor deep (mirrors real scroll: #151
        // per-window editors own top_row) while leaving the SLOT bridge
        // editor's viewport at its creation value (top_row=0) — exactly the
        // divergence the bug depends on.
        let fw = app.focused_window();
        if let Some(e) = app.window_editors.get_mut(&fw) {
            e.host_mut().viewport_mut().top_row = 50;
        }

        // Anchor the popup near the NEW (scrolled) cursor row: doc row 55,
        // i.e. screen row 5 relative to the real top_row=50. Pre-fix (stale
        // top_row=0), this computes screen row 55 — off the 24-row screen,
        // and the popup's own overflow-clamp math still can't pull a
        // `y=55`-anchored popup back into a 24-row buffer, so it renders
        // nowhere on screen at all.
        app.completion = Some(Completion::new(
            55,
            0,
            vec![CompletionItem::new("uniqueitemlabel")],
        ));

        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        let mut found_row: Option<u16> = None;
        'scan: for y in 0..24u16 {
            for x in 0..80u16 {
                if let Some(c) = buf.cell((x, y))
                    && c.symbol() == "u"
                {
                    let mut s = String::new();
                    for i in 0..15u16 {
                        if let Some(cc) = buf.cell((x + i, y)) {
                            s.push_str(cc.symbol());
                        }
                    }
                    if s.starts_with("uniqueitemlabe") {
                        found_row = Some(y);
                        break 'scan;
                    }
                }
            }
        }

        let row = found_row.expect(
            "completion item must render somewhere on the 24-row screen — \
             pre-fix it anchored off-screen using the stale slot viewport",
        );
        assert!(
            row <= 10,
            "completion popup must anchor NEAR the scrolled cursor row \
             (doc row 55, window top_row 50 → screen row ~5); got row {row}, \
             too far down to be anchored correctly"
        );
    }

    // ── Fix 6: smooth-scroll interpolated top never reached the renderer ───

    /// `render_window` computed the animated render top (`vp_top`, via
    /// `scroll_anim_render_top`) but only ever used it for overlays (signs,
    /// matchparen, hop, diff bands) — `viewport_owned.top_row`, the
    /// viewport `BufferView` actually paints text from, stayed at the
    /// FINAL target the whole time. So the feature was visually inert: the
    /// TEXT never moved during a scroll anim, only the (invisible without
    /// text moving too) overlay math.
    ///
    /// With an active mid-flight anim from top 0 toward target 100, the
    /// rendered text's top row must show the line at the ANIMATED top
    /// (strictly between 0 and 100), not `line100` (the target).
    #[test]
    fn scroll_anim_render_top_reaches_the_text_renderer() {
        use crate::app::{App, ScrollAnim};
        use ratatui::{Terminal, backend::TestBackend};
        use std::time::{Duration, Instant};

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("scroll_anim.txt");
        let content: String = (0..200).map(|i| format!("line{i}\n")).collect();
        std::fs::write(&path, &content).unwrap();

        let mut app = App::new(Some(path), false, None, None).unwrap();

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // First frame settles window rects/viewports/text_width.
        terminal.draw(|f| frame(f, &mut app)).unwrap();

        // Force the REAL (target) viewport top to 100, then arm a
        // long-duration (10s) mid-flight anim from 0 toward it, 5s in
        // (p=0.5). The long duration means the few microseconds between
        // reading `expected_top` below and the render call can't shift the
        // eased fraction enough to change the rounded row.
        let fw = app.focused_window();
        if let Some(e) = app.window_editors.get_mut(&fw) {
            e.host_mut().viewport_mut().top_row = 100;
        }
        app.scroll_anim = Some(ScrollAnim {
            win_id: fw,
            start_top: 0,
            target_top: 100,
            started_at: Instant::now() - Duration::from_secs(5),
            duration: Duration::from_secs(10),
        });

        let expected_top = app
            .scroll_anim_render_top(fw)
            .expect("mid-flight anim must return Some");
        assert!(
            expected_top > 0 && expected_top < 100,
            "test setup sanity: expected_top must be strictly between the \
             anim's start (0) and target (100); got {expected_top}"
        );

        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();

        let mut rows: Vec<String> = Vec::with_capacity(24);
        for y in 0..24u16 {
            let mut row = String::new();
            for x in 0..80u16 {
                if let Some(c) = buf.cell((x, y)) {
                    row.push_str(c.symbol());
                }
            }
            rows.push(row);
        }
        let rendered = rows.join("\n");

        let needle = format!("line{expected_top}");
        assert!(
            rendered.contains(&needle),
            "rendered text must show {needle:?} (the line at the ANIMATED \
             top) somewhere on screen — pre-fix, text always drew from the \
             target top (\"line100\") regardless of the animation. \
             rendered:\n{rendered}"
        );
        assert!(
            !rows[0].trim_start().starts_with("line100"),
            "the top screen row must show the animated line, not the \
             target's — got top row {:?}",
            rows[0]
        );
    }

    // ── Fixed splits render where the layout crate says (#63 Phase 2) ────────

    /// Build a two-window app whose tab holds a single split with `fixed`, draw
    /// it, and return the app so callers can inspect the rendered rects.
    ///
    /// Nothing in the editor constructs a fixed split yet (docks join the tree
    /// in Phase 3), so the only way to test the geometry is to plant one by
    /// hand.
    #[cfg(test)]
    fn draw_fixed_split_app(
        dir: window::SplitDir,
        fixed: hjkl_layout::Fixed,
    ) -> (crate::app::App, ratatui::buffer::Buffer) {
        use crate::app::App;
        use crate::app::window::{LayoutTree, Tab};
        use ratatui::{Terminal, backend::TestBackend};

        let mut app = App::new(None, false, None, None).unwrap();
        // Skip the splash screen — it short-circuits `frame` before the tree
        // is ever walked.
        app.start_screen = None;

        // Make a second window the normal way (so ids / editors / slots are
        // wired exactly as in a live session), then re-shape the tab's tree
        // into the fixed split under test.
        app.dispatch_ex("split");
        let leaves = app.layout().leaves();
        assert_eq!(leaves.len(), 2, ":split must produce two windows");
        app.tabs[0] = Tab::new(
            LayoutTree::split_fixed(
                dir,
                0.5,
                fixed,
                LayoutTree::Leaf(leaves[0]),
                LayoutTree::Leaf(leaves[1]),
            ),
            leaves[0],
        );

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Draw twice: the first frame settles window rects/viewports.
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        terminal.draw(|f| frame(f, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        (app, buf)
    }

    /// The renderer must place a `fixed` split exactly where
    /// `LayoutTree::window_rects` places it — same function, one source of
    /// truth — and record the split's full pre-separator rect in `last_rect`
    /// so resizes and border drags keep working.
    ///
    /// Before Phase 2 the renderer ignored `fixed` and used `ratio`, so every
    /// one of these cases would have rendered a half-and-half split.
    #[test]
    fn renderer_places_fixed_splits_where_the_layout_crate_says() {
        use hjkl_layout::Fixed;
        use window::SplitDir;

        for (dir, fixed) in [
            (SplitDir::Vertical, Fixed::First(20)),
            (SplitDir::Vertical, Fixed::Second(20)),
            (SplitDir::Horizontal, Fixed::First(5)),
            (SplitDir::Horizontal, Fixed::Second(5)),
        ] {
            let (app, _buf) = draw_fixed_split_app(dir, fixed);

            // The split's own rect, as recorded during the draw.
            let area = match app.layout() {
                window::LayoutTree::Split { last_rect, .. } => {
                    last_rect.expect("the renderer must record the split's rect")
                }
                _ => panic!("expected a split at the root"),
            };
            // The renderer's leaf rects must equal the headless walk's.
            let expected = app.layout().window_rects(area);
            let actual: Vec<_> = expected
                .iter()
                .map(|(id, _)| {
                    (
                        *id,
                        app.windows[*id]
                            .as_ref()
                            .and_then(|w| w.last_rect)
                            .expect("every rendered window records its rect"),
                    )
                })
                .collect();
            assert_eq!(
                actual, expected,
                "{dir:?}/{fixed:?}: rendered rects must match window_rects"
            );

            // …and the fixed child must actually render the requested number
            // of cells. A ratio-driven renderer would have split `area` in
            // half instead (39 columns for an 80-column frame).
            let extent = |r: window::LayoutRect| match dir.axis() {
                window::Axis::Col => r.w,
                window::Axis::Row => r.h,
            };
            let (rendered, requested) = match fixed {
                Fixed::First(n) => (extent(actual[0].1), n),
                Fixed::Second(n) => (extent(actual[1].1), n),
                _ => unreachable!("no other Fixed variants exist"),
            };
            assert_eq!(
                rendered, requested,
                "{dir:?}/{fixed:?}: the fixed child must render {requested} cells"
            );
            // The sibling takes the rest of the axis, minus the separator.
            let sibling = match fixed {
                Fixed::First(_) => extent(actual[1].1),
                _ => extent(actual[0].1),
            };
            assert_eq!(
                rendered + 1 + sibling,
                extent(area),
                "{dir:?}/{fixed:?}: children + separator must tile the split"
            );
        }
    }

    /// The separator glyph must be drawn in the cell `split_geometry` reserves
    /// for it — the cell `mouse::hit_test_border` lets the user grab. An
    /// off-by-one shows up as a divider drawn over a window's text, or a
    /// border that can't be dragged.
    #[test]
    fn fixed_split_separator_is_drawn_in_the_reserved_cell() {
        use hjkl_layout::Fixed;
        use window::SplitDir;

        for (dir, fixed, glyph) in [
            (SplitDir::Vertical, Fixed::First(20), "│"),
            (SplitDir::Vertical, Fixed::Second(20), "│"),
            (SplitDir::Horizontal, Fixed::First(5), "─"),
        ] {
            let (app, buf) = draw_fixed_split_app(dir, fixed);
            let area = match app.layout() {
                window::LayoutTree::Split { last_rect, .. } => last_rect.unwrap(),
                _ => panic!("expected a split at the root"),
            };
            let sep = hjkl_layout::split_geometry(area, dir, 0.5, Some(fixed))
                .separator
                .expect("this geometry has room for a separator");
            assert_eq!(
                buf.cell((sep.x, sep.y)).map(|c| c.symbol()),
                Some(glyph),
                "{dir:?}/{fixed:?}: separator must be drawn at ({}, {})",
                sep.x,
                sep.y
            );
        }
    }
}
