Skip to main content

ite_cli/
ui.rs

1//! Ratatui rendering for the tree and its modal/auxiliary surfaces. This module
2//! turns `App`, `Jump`, and keybinding view-model state into buffer cells and
3//! records viewport geometry needed by paging and mouse routing; it owns no
4//! event loop or source data.
5//!
6//! Colors stay inside the terminal's ANSI palette except for the focus
7//! background derived from the terminal's own colors (`Palette::focus_bg`). The
8//! tree deliberately avoids horizontal scrolling and large flexible-column
9//! ideals, which would make the widget render a costly off-screen canvas. The
10//! custom vertical scrollbar repaints the widget's reserved gutter until the
11//! upstream style API can express the desired quiet, dithered bar.
12
13use ratatui::buffer::Buffer;
14use ratatui::layout::Rect;
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::{Block, Cell, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget};
18use tui_treelistview::{
19    ColumnDef, ColumnWidth, TreeColumnSet, TreeExpansionState, TreeGlyphs, TreeLabelPrefix,
20    TreeLabelRenderer, TreeListView, TreeListViewStyle, TreeRowContext, tree_label_line,
21};
22use unicode_width::UnicodeWidthStr;
23
24use crate::app::{App, Mode};
25use crate::jump::Jump;
26use crate::keybindings::{
27    GRID_GAP, KeybindingEntry, KeybindingGrid, MAX_PANEL_ROWS, build_sectioned_grid,
28    truncate_with_ellipsis,
29};
30use crate::tree::{NodeId, Tree};
31
32struct Label;
33
34impl TreeLabelRenderer<Tree> for Label {
35    fn cell<'a>(
36        &'a self,
37        model: &'a Tree,
38        id: NodeId,
39        context: &TreeRowContext<'_>,
40        glyphs: &TreeGlyphs<'a>,
41    ) -> Cell<'a> {
42        let mut label = TreeLabelPrefix {
43            name: model.name(id).into(),
44            prefix: None,
45        };
46        if context.level == 0 && context.node.expansion == TreeExpansionState::Leaf {
47            label.prefix = Some(glyphs.leaf.into());
48        }
49        let mut line = tree_label_line(context, label, glyphs);
50        let state_glyph = match context.node.expansion {
51            TreeExpansionState::Leaf => glyphs.leaf,
52            TreeExpansionState::Collapsed => glyphs.collapsed,
53            TreeExpansionState::Expanded | TreeExpansionState::ForcedByFilter => glyphs.expanded,
54            TreeExpansionState::Unloaded => glyphs.unloaded,
55            TreeExpansionState::Loading => glyphs.loading,
56        };
57        if let Some(state_index) = line
58            .spans
59            .iter()
60            .take(line.spans.len().saturating_sub(1))
61            .rposition(|span| span.content == state_glyph)
62        {
63            line.spans[state_index].style = context.line_style;
64        }
65        if let Some(detail) = model.detail(id) {
66            line.push_span(Span::styled(
67                format!(" {detail}"),
68                Style::default().fg(Color::DarkGray),
69            ));
70        }
71        Cell::from(line)
72    }
73}
74
75fn columns() -> TreeColumnSet<'static, Tree> {
76    // Note: `flexible(min, ideal)` — the ideal must stay small. A huge ideal
77    // makes the widget lay out a virtual canvas of that width and render the
78    // whole thing every frame (a ~300ms/frame debug-build regression).
79    TreeColumnSet::new([ColumnDef::tree(
80        "",
81        ColumnWidth::flexible(1, 40).expect("valid width"),
82    )])
83    .expect("a single tree column is valid")
84    .without_header()
85}
86
87/// Guides with no horizontal tails: `├ • file`, `├ ▼ dir`.
88///
89/// The widget draws each row as `<guides><space><state-glyph> <name>`, so the
90/// disclosure triangle lands one column past the guides. To keep a child's stem
91/// directly beneath its parent's triangle, the ancestor guides (`vert`,
92/// `indent`, `empty`) are two columns wide while the branch stems (`branch`,
93/// `branch_last`) stay one column — the widget's own separator supplies the
94/// branch's second column. That extra column per ancestor is exactly what lines
95/// `│`/`├`/`└` up under the triangle they hang from.
96const GLYPHS: TreeGlyphs<'static> = TreeGlyphs {
97    indent: "  ",
98    branch_last: "└",
99    branch: "├",
100    vert: "│ ",
101    empty: "  ",
102    leaf: "•",
103    expanded: "▼",
104    collapsed: "▶",
105    // Deliberately the collapsed glyph: unloaded-vs-loaded is bookkeeping,
106    // not something the user should see. It also covers walked-empty
107    // containers, which report `Unloaded` to stay branches.
108    unloaded: "▶",
109    loading: "◌",
110};
111
112/// The terminal's default foreground and background, queried at startup.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub struct Palette {
115    pub fg: (u8, u8, u8),
116    pub bg: (u8, u8, u8),
117}
118
119impl Palette {
120    /// The focus-bar background: the default foreground blended over the
121    /// default background at 10% opacity (terminals have no real
122    /// translucency, so we premix the color).
123    pub fn focus_bg(&self) -> Color {
124        let blend = |bg: u8, fg: u8| ((u16::from(bg) * 9 + u16::from(fg) + 5) / 10) as u8;
125        Color::Rgb(
126            blend(self.bg.0, self.fg.0),
127            blend(self.bg.1, self.fg.1),
128            blend(self.bg.2, self.fg.2),
129        )
130    }
131}
132
133/// Focus uses a translucent-looking blend of the terminal's own colors when
134/// known, and falls back to reverse video. Tree chrome uses ANSI foreground
135/// color 8. No border, no header.
136fn style(palette: Option<Palette>) -> TreeListViewStyle<'static> {
137    TreeListViewStyle {
138        highlight_style: focus_style(palette),
139        line_style: Style::default().fg(Color::DarkGray),
140        highlight_symbol: "",
141        // Long names truncate at the viewport edge instead of paying for the
142        // widget's off-screen virtual canvas.
143        horizontal_scroll: tui_treelistview::TreeHorizontalScroll::Disabled,
144        ..TreeListViewStyle::borderless()
145    }
146}
147
148fn focus_style(palette: Option<Palette>) -> Style {
149    match palette {
150        Some(palette) => Style::default().bg(palette.focus_bg()),
151        None => Style::default().add_modifier(Modifier::REVERSED),
152    }
153}
154
155/// Repaint the vertical scrollbar over the one `TreeListView` just drew.
156///
157/// `TreeListViewStyle` exposes no scrollbar knobs, so the widget always paints
158/// `Scrollbar::default()` — a `║` track and `█` thumb capped with `▲`/`▼`, all
159/// at the terminal's full default foreground, shouting over the tree beside it.
160/// It does reserve the gutter, though, so we paint our own bar into that column
161/// afterwards rather than trying to restyle its glyphs in place.
162///
163/// The geometry is the widget's own: it reserves the gutter exactly when the
164/// projection overflows the viewport (there is no header and no horizontal bar
165/// to shorten it), and drives the bar from `offset` over `len - viewport + 1`
166/// positions. Both are read back off the state it just clamped, so the two stay
167/// in agreement. A patch making this configurable upstream is on the
168/// `scrollbar-style` branch of the fork; when it lands, delete this and set
169/// `vertical_scrollbar: Some(scrollbar())` in [`style`] instead.
170fn render_scrollbar(app: &App, area: Rect, buf: &mut Buffer) {
171    let total = app.state.projection().len();
172    let viewport = area.height as usize;
173    if area.width == 0 || total <= viewport {
174        return;
175    }
176    let gutter = Rect {
177        x: area.x + area.width - 1,
178        width: 1,
179        ..area
180    };
181    render_gutter_scrollbar(gutter, total - viewport, app.state.offset(), viewport, buf);
182}
183
184/// Paint the quiet bar into a one-cell gutter: `scrollable + 1` thumb
185/// positions (one per reachable offset) over a `viewport`-row window.
186fn render_gutter_scrollbar(
187    gutter: Rect,
188    scrollable: usize,
189    position: usize,
190    viewport: usize,
191    buf: &mut Buffer,
192) {
193    let mut state = ScrollbarState::new(scrollable + 1)
194        .position(position)
195        .viewport_content_length(viewport);
196    scrollbar().render(gutter, buf, &mut state);
197}
198
199/// The scrollbar is chrome, so it stays quiet: ANSI color 8 at half opacity,
200/// and no arrow caps.
201///
202/// The opacity is dithered rather than blended: `▒` covers half of its cell and
203/// `░` a quarter, so the terminal's own background shows through at a fixed
204/// ratio. That keeps the bar inside the ANSI palette and makes it identical in
205/// terminals that never answered the OSC 10/11 query.
206fn scrollbar() -> Scrollbar<'static> {
207    Scrollbar::default()
208        .thumb_symbol("▒")
209        .track_symbol(Some("░"))
210        .begin_symbol(None)
211        .end_symbol(None)
212        .style(Style::default().fg(Color::DarkGray))
213}
214
215struct PanelLayout {
216    area: Rect,
217    /// Where entry text goes: inside the border, padding, and scrollbar chrome.
218    content: Rect,
219    grid: KeybindingGrid,
220    overflow: bool,
221}
222
223fn keybinding_panel_layout(
224    entries: &[KeybindingEntry],
225    user_entry_count: usize,
226    screen: Rect,
227) -> PanelLayout {
228    let width_without_chrome = |scrollbar: bool| {
229        usize::from(screen.width)
230            .saturating_sub(2) // the Block border
231            .saturating_sub(2) // one cell of horizontal padding
232            .saturating_sub(usize::from(scrollbar))
233    };
234    let max_body_rows = usize::from(screen.height / 2).min(MAX_PANEL_ROWS);
235    let mut grid = build_sectioned_grid(entries, user_entry_count, width_without_chrome(false));
236    let mut viewport_rows = grid.rows.len().min(max_body_rows);
237    let mut overflow = grid.rows.len() > viewport_rows;
238    if overflow {
239        grid = build_sectioned_grid(entries, user_entry_count, width_without_chrome(true));
240        viewport_rows = grid.rows.len().min(max_body_rows);
241        overflow = grid.rows.len() > viewport_rows;
242    }
243
244    // `viewport_rows` is capped at MAX_PANEL_ROWS, so the height fits u16.
245    let height = ((viewport_rows + 2) as u16).min(screen.height);
246    let area = Rect::new(
247        screen.x,
248        screen.y + screen.height.saturating_sub(height),
249        screen.width,
250        height,
251    );
252    let content = Rect::new(
253        area.x + 2, // the Block border plus one cell of horizontal padding
254        area.y + 1,
255        width_without_chrome(overflow) as u16,
256        height.saturating_sub(2),
257    );
258    PanelLayout {
259        area,
260        content,
261        grid,
262        overflow,
263    }
264}
265
266fn render_keybinding_panel(app: &mut App, layout: &PanelLayout, buf: &mut Buffer) {
267    let entries = &app.panel_entries;
268    let block_style = Style::default()
269        .bg(Color::Reset)
270        .remove_modifier(Modifier::REVERSED);
271    let block = Block::bordered()
272        .style(block_style)
273        .border_style(Style::default().fg(Color::Reset));
274    let inner = block.inner(layout.area);
275    block.render(layout.area, buf);
276    let viewport_rows = usize::from(layout.content.height);
277    app.keybinding_panel
278        .record_layout(layout.area, layout.grid.rows.len(), viewport_rows);
279
280    if inner.width == 0 || inner.height == 0 {
281        return;
282    }
283
284    let content = layout.content;
285    let key_style = Style::default()
286        .fg(Color::Reset)
287        .add_modifier(Modifier::BOLD);
288    let description_style = Style::default().fg(Color::DarkGray);
289    let separator_style = Style::default().fg(Color::DarkGray);
290    let start = app.keybinding_panel.scroll();
291    let end = (start + viewport_rows).min(layout.grid.rows.len());
292    for (visible_row, row) in layout.grid.rows[start..end].iter().enumerate() {
293        let y = content.y + visible_row as u16;
294        if row.is_empty() {
295            let separator_width = inner.width.saturating_sub(u16::from(layout.overflow));
296            buf.set_stringn(
297                inner.x,
298                y,
299                "─".repeat(usize::from(separator_width)),
300                usize::from(separator_width),
301                separator_style,
302            );
303            continue;
304        }
305        for (column, &entry_index) in row.iter().enumerate() {
306            let entry = &entries[entry_index];
307            // Grid widths derive from the u16 screen width, so they fit u16.
308            let x = content.x + (column * (layout.grid.column_width + GRID_GAP)) as u16;
309            let column_width = layout
310                .grid
311                .column_width
312                .min(usize::from(content.right().saturating_sub(x)));
313            if column_width == 0 {
314                continue;
315            }
316
317            // Right-align the key label within the key column.
318            let key_width = layout.grid.key_width.min(column_width);
319            let label_width = UnicodeWidthStr::width(entry.label.full.as_str()).min(key_width);
320            buf.set_stringn(
321                x + (key_width - label_width) as u16,
322                y,
323                &entry.label.full,
324                label_width,
325                key_style,
326            );
327
328            let description_width = column_width.saturating_sub(key_width + 1);
329            if description_width > 0 {
330                buf.set_stringn(
331                    x + (key_width + 1) as u16,
332                    y,
333                    truncate_with_ellipsis(&entry.description, description_width),
334                    description_width,
335                    description_style,
336                );
337            }
338        }
339    }
340
341    if layout.overflow {
342        let gutter = Rect::new(inner.right() - 1, inner.y, 1, inner.height);
343        render_gutter_scrollbar(
344            gutter,
345            layout.grid.rows.len().saturating_sub(viewport_rows),
346            app.keybinding_panel.scroll(),
347            viewport_rows,
348            buf,
349        );
350    }
351}
352
353/// Render the current mode into `area`. In a modal picker the tree is hidden;
354/// otherwise the tree list is drawn and the viewport height recorded for paging.
355pub fn draw(app: &mut App, area: Rect, buf: &mut Buffer) {
356    // The recorded layout resets every frame and only the panel-painting path
357    // below records a fresh one, so a mode that never draws the panel cannot
358    // leave a stale rectangle capturing mouse events.
359    app.keybinding_panel.clear_layout();
360    if let Mode::Jump(_) = app.mode {
361        let palette = app.palette;
362        let target = jump_area(area);
363        if let Mode::Jump(jump) = &mut app.mode {
364            render_jump(jump, target, buf, palette);
365        }
366        return;
367    }
368    if let Mode::Indexing = app.mode {
369        render_indexing(&app.tree, area, buf);
370        return;
371    }
372
373    let panel = app
374        .keybinding_panel
375        .is_open()
376        .then(|| keybinding_panel_layout(&app.panel_entries, app.panel_user_entry_count, area));
377    let mut tree_area = match &panel {
378        Some(layout) => Rect::new(
379            area.x,
380            area.y,
381            area.width,
382            layout.area.y.saturating_sub(area.y),
383        ),
384        None => area,
385    };
386
387    // Corrupt spans and failed effects surface as a persistent banner; the
388    // tree shifts down within the viewport left above the keybinding panel.
389    if !app.tree.errors().is_empty() && tree_area.height > 0 {
390        let banner = format!(
391            "⚠ {} error(s) — details on stderr at exit",
392            app.tree.errors().len()
393        );
394        buf.set_stringn(
395            tree_area.x,
396            tree_area.y,
397            &banner,
398            tree_area.width as usize,
399            Style::default().fg(Color::Red),
400        );
401        tree_area = Rect {
402            y: tree_area.y + 1,
403            height: tree_area.height - 1,
404            ..tree_area
405        };
406    }
407    app.page_height = tree_area.height as usize;
408    {
409        let _span = crate::profile::span("ui::ensure_projection");
410        app.state.ensure_projection(&app.tree, &app.query);
411    }
412    if tree_area.width > 0 && tree_area.height > 0 {
413        let _span = crate::profile::span("ui::widget_render");
414        let columns = columns();
415        let widget = TreeListView::new(&app.tree, &app.query, &Label, &columns, style(app.palette))
416            .glyphs(GLYPHS);
417        widget.render(tree_area, buf, &mut app.state);
418        render_scrollbar(app, tree_area, buf);
419    }
420    if let Some(layout) = panel {
421        render_keybinding_panel(app, &layout, buf);
422    }
423}
424
425/// The blocking progress screen shown when `/` is pressed before the index
426/// completes: one dim status line, esc to cancel back to the tree.
427fn render_indexing(tree: &crate::tree::Tree, area: Rect, buf: &mut Buffer) {
428    if area.width == 0 || area.height == 0 {
429        return;
430    }
431    let status = format!(
432        "indexing… {} nodes · {} pending · esc cancels",
433        tree.len(),
434        tree.pending()
435    );
436    buf.set_stringn(
437        area.x,
438        area.y,
439        &status,
440        area.width as usize,
441        Style::default().fg(Color::DarkGray),
442    );
443}
444
445/// The jump picker's placement. Today it is the whole screen; moving it to a
446/// floating window or a split pane later changes only this function (plus any
447/// border/clear chrome) — `render_jump` is agnostic to the `Rect` it receives.
448/// See docs/adr/0002-surface-agnostic-jump-picker.md.
449fn jump_area(screen: Rect) -> Rect {
450    screen
451}
452
453/// Render the jump picker into `area`: a `/query` prompt with a right-aligned
454/// `matched/total` counter, a divider line, then the ranked results with matched
455/// characters highlighted and the selected row barred. Knows nothing about where
456/// `area` is; full-screen is just the identity placement above.
457pub fn render_jump(jump: &mut Jump, area: Rect, buf: &mut Buffer, palette: Option<Palette>) {
458    if area.width == 0 || area.height == 0 {
459        return;
460    }
461    let width = area.width;
462    // Row 0 is the prompt, row 1 a divider, rows 2.. the results.
463    let rows = area.height.saturating_sub(2) as usize;
464    jump.set_viewport(rows);
465
466    // Prompt: a dim `/ ` (matching the counter), then the editable query field,
467    // then a right-aligned counter.
468    let dim = Style::default().fg(Color::DarkGray);
469    let (query_x, _) = buf.set_stringn(area.x, area.y, "/ ", width as usize, dim);
470    let counter = format!("{}/{}", jump.matched(), jump.total());
471    let counter_w = counter.chars().count() as u16;
472    // The query field runs from `query_x` up to a gap before the counter.
473    let right = area.x + width;
474    let field_end = right.saturating_sub(counter_w + 1).max(query_x);
475    let field_w = field_end - query_x;
476    if field_w > 0 {
477        let scroll = jump.visual_scroll(field_w as usize);
478        Paragraph::new(jump.query())
479            .scroll((0, scroll as u16))
480            .render(Rect::new(query_x, area.y, field_w, 1), buf);
481        // A block cursor (reverse video) at the caret — there is no real cursor
482        // in the alt screen.
483        let caret = query_x + (jump.visual_cursor().saturating_sub(scroll)) as u16;
484        buf[(caret.min(field_end - 1), area.y)]
485            .set_style(Style::default().add_modifier(Modifier::REVERSED));
486    }
487    if counter_w < width {
488        buf.set_stringn(right - counter_w, area.y, &counter, counter_w as usize, dim);
489    }
490
491    // A single divider line under the input, separating it from the results.
492    if area.height >= 2 {
493        let divider = "─".repeat(width as usize);
494        buf.set_stringn(
495            area.x,
496            area.y + 1,
497            &divider,
498            width as usize,
499            Style::default().fg(Color::DarkGray),
500        );
501    }
502
503    let match_style = Style::default()
504        .fg(Color::Cyan)
505        .add_modifier(Modifier::BOLD);
506    let start = jump.scroll();
507    let selected = jump.selected();
508    let results = jump.results();
509    let end = (start + rows).min(results.len());
510    for (row, res) in results[start..end].iter().enumerate() {
511        let y = area.y + 2 + row as u16;
512        let line = Line::from(highlight_spans(
513            jump.path(res.id),
514            &res.indices,
515            match_style,
516        ));
517        buf.set_line(area.x, y, &line, width);
518        if start + row == selected {
519            highlight_row(buf, area.x, y, width, palette);
520        }
521    }
522}
523
524/// Bar the selected row: the terminal-derived focus blend when known, else
525/// reverse video (mirrors the tree's focus styling and the palette-0–16 rule).
526fn highlight_row(buf: &mut Buffer, x0: u16, y: u16, width: u16, palette: Option<Palette>) {
527    for x in x0..x0 + width {
528        let cell = &mut buf[(x, y)];
529        match palette {
530            Some(p) => {
531                cell.set_bg(p.focus_bg());
532            }
533            None => {
534                cell.set_style(Style::default().add_modifier(Modifier::REVERSED));
535            }
536        }
537    }
538}
539
540/// Split `path` into spans, styling the matched character positions. `indices`
541/// are char offsets, sorted and deduplicated by the picker.
542fn highlight_spans(path: &str, indices: &[u32], match_style: Style) -> Vec<Span<'static>> {
543    let mut spans: Vec<Span<'static>> = Vec::new();
544    let mut run = String::new();
545    let mut run_matched = false;
546    for (i, chr) in path.chars().enumerate() {
547        let matched = indices.binary_search(&(i as u32)).is_ok();
548        if !run.is_empty() && matched != run_matched {
549            spans.push(span(std::mem::take(&mut run), run_matched, match_style));
550        }
551        run.push(chr);
552        run_matched = matched;
553    }
554    if !run.is_empty() {
555        spans.push(span(run, run_matched, match_style));
556    }
557    spans
558}
559
560fn span(text: String, matched: bool, match_style: Style) -> Span<'static> {
561    if matched {
562        Span::styled(text, match_style)
563    } else {
564        Span::raw(text)
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::cli::ExpandSpec;
572    use crate::config::Config;
573    use crate::fstree;
574    use crate::tree::{ActionValues, Tree};
575    use ratatui::buffer::Buffer;
576
577    fn drawn(app: &mut App, width: u16, height: u16) -> (Buffer, String) {
578        let area = Rect::new(0, 0, width, height);
579        let mut buf = Buffer::empty(area);
580        draw(app, area, &mut buf);
581        let text: String = (0..height)
582            .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>() + "\n")
583            .collect();
584        (buf, text)
585    }
586
587    fn fixture_app() -> (tempfile::TempDir, App) {
588        let dir = tempfile::tempdir().unwrap();
589        std::fs::create_dir(dir.path().join("subdir")).unwrap();
590        std::fs::write(dir.path().join("subdir/inner.txt"), "").unwrap();
591        std::fs::write(dir.path().join("subdir/last.txt"), "").unwrap();
592        std::fs::write(dir.path().join("file.txt"), "").unwrap();
593        let tree = fstree::scan(dir.path(), false).unwrap();
594        let app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
595        (dir, app)
596    }
597
598    #[test]
599    fn tree_guides_have_no_horizontal_tails() {
600        let (_d, mut app) = fixture_app();
601        let (_buf, text) = drawn(&mut app, 40, 10);
602        assert!(
603            text.contains("├ • inner.txt"),
604            "expected `├ • inner.txt` in:\n{text}"
605        );
606        assert!(
607            text.contains("└ • last.txt"),
608            "expected `└ • last.txt` in:\n{text}"
609        );
610        assert!(!text.contains('─'), "no horizontal tails in:\n{text}");
611    }
612
613    #[test]
614    fn node_type_glyphs_follow_parent_stems_with_one_space() {
615        let mut tree = Tree::new();
616        let root = tree.push(None, "root", true, ActionValues::new("", "", ""));
617        let open = tree.push(Some(root), "open", true, ActionValues::new("", "", ""));
618        tree.push(Some(open), "nested", false, ActionValues::new("", "", ""));
619        let closed = tree.push(Some(root), "closed", true, ActionValues::new("", "", ""));
620        tree.push(Some(closed), "hidden", false, ActionValues::new("", "", ""));
621        tree.push(Some(root), "leaf", false, ActionValues::new("", "", ""));
622        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
623        app.state.set_expanded(closed, Some(root), false);
624
625        let (_buf, text) = drawn(&mut app, 40, 10);
626        let got: Vec<_> = text.lines().take(5).map(str::trim_end).collect();
627        assert_eq!(
628            got,
629            [
630                "▼ root",
631                "├ ▼ open",
632                "│ └ • nested",
633                "├ ▶ closed",
634                "└ • leaf",
635            ]
636        );
637    }
638
639    #[test]
640    fn top_level_leaves_use_the_leaf_glyph() {
641        let (_d, mut app) = fixture_app();
642        let (_buf, text) = drawn(&mut app, 40, 10);
643        let got: Vec<_> = text.lines().take(4).map(str::trim_end).collect();
644
645        assert_eq!(
646            got,
647            ["▼ subdir", "├ • inner.txt", "└ • last.txt", "• file.txt"]
648        );
649    }
650
651    #[test]
652    fn unwalked_directories_render_the_collapsed_glyph() {
653        let (_d, mut app) = {
654            let dir = tempfile::tempdir().unwrap();
655            std::fs::create_dir(dir.path().join("subdir")).unwrap();
656            std::fs::write(dir.path().join("subdir/inner.txt"), "").unwrap();
657            std::fs::write(dir.path().join("file.txt"), "").unwrap();
658            let tree = fstree::scan(dir.path(), false).unwrap();
659            (dir, App::new(tree, &Config::default(), None))
660        };
661        assert!(app.has_work(), "subdir must still be unwalked");
662        let (_buf, text) = drawn(&mut app, 40, 10);
663        assert!(text.contains("▶ subdir"), "in:\n{text}");
664        assert!(!text.contains('◇'), "no unloaded glyph in:\n{text}");
665    }
666
667    #[test]
668    fn empty_directories_render_as_collapsed_branches() {
669        let dir = tempfile::tempdir().unwrap();
670        std::fs::create_dir(dir.path().join("hollow")).unwrap();
671        std::fs::write(dir.path().join("file.txt"), "").unwrap();
672        let tree = fstree::scan(dir.path(), false).unwrap();
673        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
674        while app.do_work() {}
675
676        let (_buf, text) = drawn(&mut app, 40, 10);
677        assert!(text.contains("▶ hollow"), "in:\n{text}");
678        assert!(!text.contains("• hollow"), "not a leaf in:\n{text}");
679    }
680
681    #[test]
682    fn focus_bg_blends_foreground_at_ten_percent() {
683        let white_on_black = Palette {
684            fg: (255, 255, 255),
685            bg: (0, 0, 0),
686        };
687        assert_eq!(white_on_black.focus_bg(), Color::Rgb(26, 26, 26));
688        let mixed = Palette {
689            fg: (0, 0, 0),
690            bg: (200, 100, 50),
691        };
692        assert_eq!(mixed.focus_bg(), Color::Rgb(180, 90, 45));
693    }
694
695    #[test]
696    fn focused_row_uses_blended_bg_when_palette_known() {
697        let (_d, mut app) = fixture_app();
698        app.palette = Some(Palette {
699            fg: (255, 255, 255),
700            bg: (0, 0, 0),
701        });
702        let (buf, text) = drawn(&mut app, 40, 10);
703        // Focus starts on the first row ("subdir").
704        assert!(text.starts_with("▼ subdir"), "{text}");
705        let cell = &buf[(0, 0)];
706        assert_eq!(cell.bg, Color::Rgb(26, 26, 26), "focused bg is the blend");
707        assert!(
708            !cell.modifier.contains(Modifier::REVERSED),
709            "no reverse video when the palette is known"
710        );
711    }
712
713    #[test]
714    fn focused_row_falls_back_to_reverse_video_without_palette() {
715        let (_d, mut app) = fixture_app();
716        assert_eq!(app.palette, None);
717        let (buf, text) = drawn(&mut app, 40, 10);
718        assert!(text.starts_with("▼ subdir"), "{text}");
719        assert!(
720            buf[(0, 0)].modifier.contains(Modifier::REVERSED),
721            "reverse video fallback"
722        );
723    }
724
725    #[test]
726    fn tree_chrome_uses_ansi_color_8() {
727        let mut tree = Tree::new();
728        let outer = tree.push(None, "outer", true, ActionValues::new("", "", ""));
729        let inner = tree.push(Some(outer), "inner", true, ActionValues::new("", "", ""));
730        tree.push(Some(inner), "first", false, ActionValues::new("", "", ""));
731        tree.push(Some(inner), "last", false, ActionValues::new("", "", ""));
732        tree.push(Some(outer), "sibling", false, ActionValues::new("", "", ""));
733        let closed = tree.push(None, "closed", true, ActionValues::new("", "", ""));
734        tree.push(Some(closed), "hidden", false, ActionValues::new("", "", ""));
735        tree.push(None, "root-leaf", false, ActionValues::new("", "", ""));
736        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
737        app.state.set_expanded(closed, None, false);
738
739        let (buf, text) = drawn(&mut app, 40, 10);
740        for (x, y, symbol) in [
741            (0, 0, "▼"),
742            (0, 1, "├"),
743            (2, 1, "▼"),
744            (0, 2, "│"),
745            (2, 2, "├"),
746            (4, 2, "•"),
747            (0, 3, "│"),
748            (2, 3, "└"),
749            (4, 3, "•"),
750            (0, 4, "└"),
751            (2, 4, "•"),
752            (0, 5, "▶"),
753            (0, 6, "•"),
754        ] {
755            let cell = &buf[(x, y)];
756            assert_eq!(
757                cell.symbol(),
758                symbol,
759                "unexpected tree at ({x}, {y}):\n{text}"
760            );
761            assert_eq!(
762                cell.fg,
763                Color::DarkGray,
764                "tree glyph at ({x}, {y}) should use ANSI foreground color 8"
765            );
766        }
767    }
768
769    #[test]
770    fn node_detail_uses_ansi_color_8_while_primary_text_stays_normal() {
771        let mut tree = Tree::new();
772        let root = tree.push_with_detail(
773            None,
774            "project {4}",
775            Some(r#"name: "ite" · status: "experimental""#.to_owned()),
776            true,
777            ActionValues::new("", "", ""),
778        );
779        tree.push(
780            Some(root),
781            r#"name: "ite""#,
782            false,
783            ActionValues::new("", "", ""),
784        );
785        let mut app = App::new(tree, &Config::default(), None);
786
787        let (buf, text) = drawn(&mut app, 60, 1);
788
789        assert!(
790            // Single-rooted trees open expanded, so the root shows ▼.
791            text.starts_with(r#"▼ project {4} name: "ite" · status: "experimental""#),
792            "{text}"
793        );
794        let primary = &buf[(2, 0)];
795        assert_eq!(primary.fg, Color::Reset);
796        assert!(!primary.modifier.contains(Modifier::BOLD));
797
798        let detail = &buf[(14, 0)];
799        assert_eq!(detail.symbol(), "n");
800        assert_eq!(detail.fg, Color::DarkGray);
801        assert!(!detail.modifier.contains(Modifier::BOLD));
802    }
803
804    #[test]
805    fn renders_expanded_tree_rows() {
806        let dir = tempfile::tempdir().unwrap();
807        std::fs::create_dir(dir.path().join("subdir")).unwrap();
808        std::fs::write(dir.path().join("subdir/inner.txt"), "").unwrap();
809        std::fs::write(dir.path().join("file.txt"), "").unwrap();
810        let tree = fstree::scan(dir.path(), false).unwrap();
811        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
812
813        let area = Rect::new(0, 0, 40, 10);
814        let mut buf = Buffer::empty(area);
815        draw(&mut app, area, &mut buf);
816
817        let text: String = (0..area.height)
818            .map(|y| {
819                (0..area.width)
820                    .map(|x| buf[(x, y)].symbol())
821                    .collect::<String>()
822                    + "\n"
823            })
824            .collect();
825        assert!(text.contains("subdir"), "missing subdir in:\n{text}");
826        assert!(text.contains("inner.txt"), "missing inner.txt in:\n{text}");
827        assert!(text.contains("file.txt"), "missing file.txt in:\n{text}");
828        assert_eq!(app.page_height, 10);
829    }
830
831    /// A child's stem (`├`/`└`/`│`) must sit in the same column as its
832    /// parent container's triangle. With single-column guides the widget's
833    /// disclosure glyph lands one column past the guides, so deeper stems
834    /// used to drift left of the triangle they hang from.
835    #[test]
836    fn stems_align_with_parent_triangle() {
837        let dir = tempfile::tempdir().unwrap();
838        std::fs::create_dir_all(dir.path().join("outer/inner")).unwrap();
839        std::fs::write(dir.path().join("outer/inner/deep.txt"), "").unwrap();
840        std::fs::write(dir.path().join("outer/inner/deep2.txt"), "").unwrap();
841        std::fs::write(dir.path().join("outer/sibling.txt"), "").unwrap();
842        std::fs::write(dir.path().join("zroot.txt"), "").unwrap();
843        let tree = fstree::scan(dir.path(), false).unwrap();
844        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
845        let (_buf, text) = drawn(&mut app, 40, 12);
846        let got: String = text
847            .lines()
848            .take(6)
849            .map(|l| format!("{}\n", l.trim_end()))
850            .collect();
851        let want = "\
852▼ outer
853├ ▼ inner
854│ ├ • deep.txt
855│ └ • deep2.txt
856└ • sibling.txt
857• zroot.txt
858";
859        assert_eq!(got, want, "\ngot:\n{got}\nwant:\n{want}");
860    }
861
862    /// The scrollbar is chrome, so it follows the same rules as the tree
863    /// guides: ANSI color 8, dithered to a half-tone, and no `▲`/`▼` caps —
864    /// rather than ratatui's default `║`/`█`/`▲`/`▼` at full brightness.
865    #[test]
866    fn scrollbar_is_dim_and_uncapped() {
867        let dir = tempfile::tempdir().unwrap();
868        for i in 0..30 {
869            std::fs::write(dir.path().join(format!("file-{i:02}.txt")), "").unwrap();
870        }
871        let tree = fstree::scan(dir.path(), false).unwrap();
872        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
873
874        // 30 rows in a 10-row viewport, so the bar is drawn in the last column.
875        let (buf, text) = drawn(&mut app, 40, 10);
876        let column: Vec<&str> = (0..10).map(|y| buf[(39, y)].symbol()).collect();
877        assert!(
878            column.iter().all(|s| *s == "░" || *s == "▒"),
879            "unexpected scrollbar column {column:?} in:\n{text}"
880        );
881        assert!(column.contains(&"▒"), "no thumb drawn: {column:?}");
882        assert!(column.contains(&"░"), "no track drawn: {column:?}");
883        for y in 0..10 {
884            assert_eq!(
885                buf[(39, y)].fg,
886                Color::DarkGray,
887                "scrollbar row {y} should use ANSI foreground color 8"
888            );
889        }
890    }
891
892    /// We paint the bar into the gutter `TreeListView` reserves, deriving its
893    /// geometry from the same state the widget does. If those ever drift — a
894    /// header appearing, the reservation rule changing — the thumb stops
895    /// tracking the viewport, so pin it at both ends of the scroll range.
896    #[test]
897    fn scrollbar_thumb_tracks_the_viewport() {
898        let dir = tempfile::tempdir().unwrap();
899        for i in 0..30 {
900            std::fs::write(dir.path().join(format!("file-{i:02}.txt")), "").unwrap();
901        }
902        let tree = fstree::scan(dir.path(), false).unwrap();
903        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
904
905        let (top, _) = drawn(&mut app, 40, 10);
906        assert_eq!(top[(39, 0)].symbol(), "▒", "thumb should start at the top");
907        assert_eq!(top[(39, 9)].symbol(), "░", "track should fill the bottom");
908
909        app.state.set_offset(usize::MAX);
910        let (bottom, _) = drawn(&mut app, 40, 10);
911        assert_eq!(
912            bottom[(39, 9)].symbol(),
913            "▒",
914            "thumb should reach the bottom at the last viewport"
915        );
916        assert_eq!(bottom[(39, 0)].symbol(), "░", "track should fill the top");
917    }
918
919    /// The bar appears only when the tree actually overflows its viewport.
920    #[test]
921    fn no_scrollbar_when_everything_fits() {
922        let (_d, mut app) = fixture_app();
923        let (buf, text) = drawn(&mut app, 40, 10);
924        for y in 0..10 {
925            let symbol = buf[(39, y)].symbol();
926            assert!(
927                symbol == " " || symbol.is_empty(),
928                "unexpected scrollbar cell {symbol:?} at row {y} in:\n{text}"
929            );
930        }
931    }
932
933    /// Guards against the virtual-canvas regression: a mis-sized column made
934    /// the widget allocate and render a 65k-cell-wide buffer per frame
935    /// (~10ms). 100 draws must stay far under that regime's ~1s.
936    #[test]
937    fn repeated_draws_are_fast() {
938        let dir = tempfile::tempdir().unwrap();
939        for i in 0..30 {
940            std::fs::write(dir.path().join(format!("file-{i:02}.txt")), "").unwrap();
941        }
942        let tree = fstree::scan(dir.path(), false).unwrap();
943        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
944        let area = Rect::new(0, 0, 120, 40);
945        let mut buf = Buffer::empty(area);
946        draw(&mut app, area, &mut buf); // warm-up
947        let start = std::time::Instant::now();
948        for _ in 0..100 {
949            draw(&mut app, area, &mut buf);
950        }
951        let elapsed = start.elapsed();
952        assert!(
953            elapsed < std::time::Duration::from_millis(500),
954            "100 draws took {elapsed:?}"
955        );
956    }
957
958    #[test]
959    fn keybinding_panel_is_bottom_docked_styled_and_reduces_the_tree_viewport() {
960        use crate::keys::Key;
961        let (_d, mut app) = fixture_app();
962        app.palette = Some(Palette {
963            fg: (255, 255, 255),
964            bg: (0, 0, 0),
965        });
966        app.handle_key(Key::parse("?").unwrap());
967
968        let (buf, text) = drawn(&mut app, 80, 24);
969        let panel = app.keybinding_panel.area().expect("panel area");
970
971        assert_eq!(panel.y + panel.height, 24);
972        assert_eq!(app.page_height, panel.y as usize);
973        assert_eq!(buf[(panel.x, panel.y)].symbol(), "┌");
974        assert_eq!(buf[(panel.x, panel.y)].fg, Color::Reset);
975        assert_eq!(buf[(panel.x + 1, panel.y + 1)].bg, Color::Reset);
976        assert!(text.contains("Shortcuts"), "{text}");
977        assert!(text.contains("Close"), "{text}");
978        assert!(text.contains("First"), "{text}");
979        assert!(
980            app.panel_entries
981                .iter()
982                .all(|entry| entry.label.full != "gg"),
983            "{text}"
984        );
985
986        let layout = keybinding_panel_layout(
987            &app.panel_entries,
988            app.panel_user_entry_count,
989            Rect::new(0, 0, 80, 24),
990        );
991        let entry = &app.panel_entries[layout.grid.rows[0][0]];
992        let key_width = layout.grid.key_width;
993        let label_width = UnicodeWidthStr::width(entry.label.full.as_str());
994        let key = &buf[(
995            layout.content.x + (key_width - label_width) as u16,
996            layout.content.y,
997        )];
998        assert_eq!(
999            key.fg,
1000            Color::Reset,
1001            "key should use the default foreground"
1002        );
1003        assert!(key.modifier.contains(Modifier::BOLD), "key should be bold");
1004
1005        let description = &buf[(layout.content.x + key_width as u16 + 1, layout.content.y)];
1006        assert_eq!(
1007            description.fg,
1008            Color::DarkGray,
1009            "description should use ANSI 8"
1010        );
1011        assert!(
1012            !description.modifier.contains(Modifier::BOLD),
1013            "description should not be bold"
1014        );
1015    }
1016
1017    #[test]
1018    fn user_keybindings_are_above_builtin_bindings_with_a_full_width_gray_rule() {
1019        use crate::keys::Key;
1020
1021        let mut tree = Tree::new();
1022        tree.push(None, "leaf", false, ActionValues::new("", "", ""));
1023        let config = Config::parse(
1024            r#"
1025[j]
1026cmd = "quit"
1027help = "Custom quit"
1028
1029[x]
1030sh = "printf custom"
1031help = "Custom action"
1032"#,
1033        )
1034        .unwrap();
1035        let mut app = App::new(tree, &config, None);
1036        let configured_j: Vec<_> = app
1037            .panel_entries
1038            .iter()
1039            .enumerate()
1040            .filter(|(_, entry)| entry.key == Key::parse("j").unwrap())
1041            .collect();
1042        assert_eq!(
1043            configured_j.len(),
1044            1,
1045            "an override should not also be built in"
1046        );
1047        assert!(configured_j[0].0 < app.panel_user_entry_count);
1048        app.palette = Some(Palette {
1049            fg: (255, 255, 255),
1050            bg: (0, 0, 0),
1051        });
1052        app.handle_key(Key::parse("?").unwrap());
1053
1054        let (buf, text) = drawn(&mut app, 80, 30);
1055        let panel = app.keybinding_panel.area().expect("panel area");
1056        let separator_y = (panel.y + 1..panel.bottom() - 1)
1057            .find(|&y| {
1058                (panel.x + 1..panel.right() - 1).all(|x| {
1059                    let cell = &buf[(x, y)];
1060                    cell.symbol() == "─" && cell.fg == Color::DarkGray
1061                })
1062            })
1063            .unwrap_or_else(|| panic!("missing full-width ANSI-8 separator:\n{text}"));
1064        let row_containing = |needle: &str| {
1065            text.lines()
1066                .position(|line| line.contains(needle))
1067                .unwrap_or_else(|| panic!("missing {needle:?} in:\n{text}")) as u16
1068        };
1069
1070        assert!(row_containing("Custom quit") < separator_y, "{text}");
1071        assert!(row_containing("Custom action") < separator_y, "{text}");
1072        assert!(row_containing("Shortcuts") > separator_y, "{text}");
1073    }
1074
1075    #[test]
1076    fn overflowing_keybinding_panel_reuses_the_app_scrollbar_style() {
1077        use crate::keys::Key;
1078        let (_d, mut app) = fixture_app();
1079        app.handle_key(Key::parse("?").unwrap());
1080
1081        let (buf, text) = drawn(&mut app, 30, 12);
1082        let panel = app.keybinding_panel.area().expect("panel area");
1083        assert_eq!(panel.height, 8, "six body rows plus the border");
1084
1085        let x = panel.x + panel.width - 2;
1086        let symbols: Vec<_> = (panel.y + 1..panel.y + panel.height - 1)
1087            .map(|y| buf[(x, y)].symbol())
1088            .collect();
1089        assert!(
1090            symbols
1091                .iter()
1092                .all(|symbol| *symbol == "▒" || *symbol == "░"),
1093            "unexpected scrollbar {symbols:?}:\n{text}"
1094        );
1095        assert!(symbols.contains(&"▒"));
1096        assert!(symbols.contains(&"░"));
1097        assert_eq!(buf[(x, panel.y + 1)].fg, Color::DarkGray);
1098    }
1099
1100    #[test]
1101    fn keybinding_panel_uses_default_background_when_the_palette_is_unknown() {
1102        use crate::keys::Key;
1103        let (_d, mut app) = fixture_app();
1104        app.handle_key(Key::parse("?").unwrap());
1105
1106        let (buf, _text) = drawn(&mut app, 80, 24);
1107        let panel = app.keybinding_panel.area().expect("panel area");
1108
1109        let body = &buf[(panel.x + 1, panel.y + 1)];
1110        assert_eq!(body.bg, Color::Reset);
1111        assert!(!body.modifier.contains(Modifier::REVERSED));
1112        let border = &buf[(panel.x, panel.y)];
1113        assert_eq!(border.fg, Color::Reset);
1114        assert!(
1115            !border.modifier.contains(Modifier::REVERSED),
1116            "the fallback must not reverse the default-foreground border"
1117        );
1118    }
1119
1120    #[test]
1121    fn jump_picker_renders_prompt_results_and_highlights() {
1122        use crate::keys::Key;
1123        let (_d, mut app) = fixture_app();
1124        app.handle_key(Key::parse("/").unwrap());
1125        for k in ["i", "n", "n", "e", "r"] {
1126            app.handle_key(Key::parse(k).unwrap());
1127        }
1128        let (buf, text) = drawn(&mut app, 40, 10);
1129        let lines: Vec<&str> = text.lines().collect();
1130        // A dim `/ ` prefix (same color as the counter), then the query.
1131        assert!(
1132            lines[0].starts_with("/ inner"),
1133            "prompt row: {:?}",
1134            lines[0]
1135        );
1136        assert_eq!(buf[(0, 0)].symbol(), "/");
1137        assert_eq!(buf[(0, 0)].fg, Color::DarkGray);
1138        // A block cursor (reverse video) sits at the caret, just past the query.
1139        assert!(
1140            buf[(7, 0)].modifier.contains(Modifier::REVERSED),
1141            "expected a block cursor after `/ inner`"
1142        );
1143        // The counter shows one match out of the four candidate nodes.
1144        assert!(
1145            lines[0].trim_end().ends_with("1/4"),
1146            "counter row: {:?}",
1147            lines[0]
1148        );
1149        // Row 1 is a full-width divider under the input.
1150        assert_eq!(lines[1], "─".repeat(40), "divider row: {:?}", lines[1]);
1151        // Results begin on row 2.
1152        assert!(lines[2].contains("inner.txt"), "result row: {:?}", lines[2]);
1153        // At least one matched character in the results renders cyan + bold.
1154        let highlighted = (0..40).any(|x| {
1155            (2..10).any(|y| {
1156                let cell = &buf[(x, y)];
1157                cell.fg == Color::Cyan && cell.modifier.contains(Modifier::BOLD)
1158            })
1159        });
1160        assert!(highlighted, "expected a highlighted match cell:\n{text}");
1161    }
1162}