Skip to main content

ite_cli/
ui.rs

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