Skip to main content

ite_cli/
ui.rs

1//! Rendering: a single-column tree list using only the terminal's ANSI palette.
2
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Cell, Paragraph, StatefulWidget, Widget};
8use tui_treelistview::{
9    ColumnDef, ColumnWidth, TreeColumnSet, TreeExpansionState, TreeGlyphs, TreeLabelPrefix,
10    TreeLabelRenderer, TreeListView, TreeListViewStyle, TreeRowContext, tree_label_line,
11};
12
13use crate::app::{App, Mode};
14use crate::jump::Jump;
15use crate::tree::{NodeId, Tree};
16
17struct Label;
18
19impl TreeLabelRenderer<Tree> for Label {
20    fn cell<'a>(
21        &'a self,
22        model: &'a Tree,
23        id: NodeId,
24        context: &TreeRowContext<'_>,
25        glyphs: &TreeGlyphs<'a>,
26    ) -> Cell<'a> {
27        let node = model.node(id);
28        let mut label = TreeLabelPrefix::borrowed(&node.name);
29        if context.level == 0 && context.node.expansion == TreeExpansionState::Leaf {
30            label.prefix = Some(glyphs.leaf.into());
31        }
32        let mut line = tree_label_line(context, label, glyphs);
33        let state_glyph = match context.node.expansion {
34            TreeExpansionState::Leaf => glyphs.leaf,
35            TreeExpansionState::Collapsed => glyphs.collapsed,
36            TreeExpansionState::Expanded | TreeExpansionState::ForcedByFilter => glyphs.expanded,
37            TreeExpansionState::Unloaded => glyphs.unloaded,
38            TreeExpansionState::Loading => glyphs.loading,
39        };
40        if let Some(state_index) = line
41            .spans
42            .iter()
43            .take(line.spans.len().saturating_sub(1))
44            .rposition(|span| span.content == state_glyph)
45        {
46            line.spans[state_index].style = context.line_style;
47        }
48        if let Some(detail) = &node.detail {
49            line.push_span(Span::styled(
50                format!(" {detail}"),
51                Style::default().fg(Color::DarkGray),
52            ));
53        }
54        Cell::from(line)
55    }
56}
57
58fn columns() -> TreeColumnSet<'static, Tree> {
59    // Note: `flexible(min, ideal)` — the ideal must stay small. A huge ideal
60    // makes the widget lay out a virtual canvas of that width and render the
61    // whole thing every frame (a ~300ms/frame debug-build regression).
62    TreeColumnSet::new([ColumnDef::tree(
63        "",
64        ColumnWidth::flexible(1, 40).expect("valid width"),
65    )])
66    .expect("a single tree column is valid")
67    .without_header()
68}
69
70/// Guides with no horizontal tails: `├ • file`, `├ ▼ dir`.
71///
72/// The widget draws each row as `<guides><space><state-glyph> <name>`, so the
73/// disclosure triangle lands one column past the guides. To keep a child's stem
74/// directly beneath its parent's triangle, the ancestor guides (`vert`,
75/// `indent`, `empty`) are two columns wide while the branch stems (`branch`,
76/// `branch_last`) stay one column — the widget's own separator supplies the
77/// branch's second column. That extra column per ancestor is exactly what lines
78/// `│`/`├`/`└` up under the triangle they hang from.
79const GLYPHS: TreeGlyphs<'static> = TreeGlyphs {
80    indent: "  ",
81    branch_last: "└",
82    branch: "├",
83    vert: "│ ",
84    empty: "  ",
85    leaf: "•",
86    expanded: "▼",
87    collapsed: "▶",
88    unloaded: "◇",
89    loading: "◌",
90};
91
92/// The terminal's default foreground and background, queried at startup.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub struct Palette {
95    pub fg: (u8, u8, u8),
96    pub bg: (u8, u8, u8),
97}
98
99impl Palette {
100    /// The focus-bar background: the default foreground blended over the
101    /// default background at 10% opacity (terminals have no real
102    /// translucency, so we premix the color).
103    pub fn focus_bg(&self) -> Color {
104        let blend = |bg: u8, fg: u8| ((u16::from(bg) * 9 + u16::from(fg) + 5) / 10) as u8;
105        Color::Rgb(
106            blend(self.bg.0, self.fg.0),
107            blend(self.bg.1, self.fg.1),
108            blend(self.bg.2, self.fg.2),
109        )
110    }
111}
112
113/// Focus uses a translucent-looking blend of the terminal's own colors when
114/// known, and falls back to reverse video. Tree chrome uses ANSI foreground
115/// color 8. No border, no header.
116fn style(palette: Option<Palette>) -> TreeListViewStyle<'static> {
117    let highlight_style = match palette {
118        Some(palette) => Style::default().bg(palette.focus_bg()),
119        None => Style::default().add_modifier(Modifier::REVERSED),
120    };
121    TreeListViewStyle {
122        highlight_style,
123        line_style: Style::default().fg(Color::DarkGray),
124        highlight_symbol: "",
125        // Long names truncate at the viewport edge instead of paying for the
126        // widget's off-screen virtual canvas.
127        horizontal_scroll: tui_treelistview::TreeHorizontalScroll::Disabled,
128        ..TreeListViewStyle::borderless()
129    }
130}
131
132/// Render the current mode into `area`. In a modal picker the tree is hidden;
133/// otherwise the tree list is drawn and the viewport height recorded for paging.
134pub fn draw(app: &mut App, area: Rect, buf: &mut Buffer) {
135    if let Mode::Jump(_) = app.mode {
136        let palette = app.palette;
137        let target = jump_area(area);
138        if let Mode::Jump(jump) = &mut app.mode {
139            render_jump(jump, target, buf, palette);
140        }
141        return;
142    }
143    app.page_height = area.height as usize;
144    {
145        let _span = crate::profile::span("ui::ensure_projection");
146        app.state.ensure_projection(&app.tree, &app.query);
147    }
148    let _span = crate::profile::span("ui::widget_render");
149    let columns = columns();
150    let widget = TreeListView::new(&app.tree, &app.query, &Label, &columns, style(app.palette))
151        .glyphs(GLYPHS);
152    widget.render(area, buf, &mut app.state);
153}
154
155/// The jump picker's placement. Today it is the whole screen; moving it to a
156/// floating window or a split pane later changes only this function (plus any
157/// border/clear chrome) — `render_jump` is agnostic to the `Rect` it receives.
158/// See docs/adr/0002-surface-agnostic-jump-picker.md.
159fn jump_area(screen: Rect) -> Rect {
160    screen
161}
162
163/// Render the jump picker into `area`: a `/query` prompt with a right-aligned
164/// `matched/total` counter, a divider line, then the ranked results with matched
165/// characters highlighted and the selected row barred. Knows nothing about where
166/// `area` is; full-screen is just the identity placement above.
167pub fn render_jump(jump: &mut Jump, area: Rect, buf: &mut Buffer, palette: Option<Palette>) {
168    if area.width == 0 || area.height == 0 {
169        return;
170    }
171    let width = area.width;
172    // Row 0 is the prompt, row 1 a divider, rows 2.. the results.
173    let rows = area.height.saturating_sub(2) as usize;
174    jump.set_viewport(rows);
175
176    // Prompt: a dim `/ ` (matching the counter), then the editable query field,
177    // then a right-aligned counter.
178    let dim = Style::default().fg(Color::DarkGray);
179    let (query_x, _) = buf.set_stringn(area.x, area.y, "/ ", width as usize, dim);
180    let counter = format!("{}/{}", jump.matched(), jump.total());
181    let counter_w = counter.chars().count() as u16;
182    // The query field runs from `query_x` up to a gap before the counter.
183    let right = area.x + width;
184    let field_end = right.saturating_sub(counter_w + 1).max(query_x);
185    let field_w = field_end - query_x;
186    if field_w > 0 {
187        let scroll = jump.visual_scroll(field_w as usize);
188        Paragraph::new(jump.query())
189            .scroll((0, scroll as u16))
190            .render(Rect::new(query_x, area.y, field_w, 1), buf);
191        // A block cursor (reverse video) at the caret — there is no real cursor
192        // in the alt screen.
193        let caret = query_x + (jump.visual_cursor().saturating_sub(scroll)) as u16;
194        buf[(caret.min(field_end - 1), area.y)]
195            .set_style(Style::default().add_modifier(Modifier::REVERSED));
196    }
197    if counter_w < width {
198        buf.set_stringn(right - counter_w, area.y, &counter, counter_w as usize, dim);
199    }
200
201    // A single divider line under the input, separating it from the results.
202    if area.height >= 2 {
203        let divider = "─".repeat(width as usize);
204        buf.set_stringn(
205            area.x,
206            area.y + 1,
207            &divider,
208            width as usize,
209            Style::default().fg(Color::DarkGray),
210        );
211    }
212
213    let match_style = Style::default()
214        .fg(Color::Cyan)
215        .add_modifier(Modifier::BOLD);
216    let start = jump.scroll();
217    let selected = jump.selected();
218    let results = jump.results();
219    let end = (start + rows).min(results.len());
220    for (row, res) in results[start..end].iter().enumerate() {
221        let y = area.y + 2 + row as u16;
222        let line = Line::from(highlight_spans(
223            jump.path(res.id),
224            &res.indices,
225            match_style,
226        ));
227        buf.set_line(area.x, y, &line, width);
228        if start + row == selected {
229            highlight_row(buf, area.x, y, width, palette);
230        }
231    }
232}
233
234/// Bar the selected row: the terminal-derived focus blend when known, else
235/// reverse video (mirrors the tree's focus styling and the palette-0–16 rule).
236fn highlight_row(buf: &mut Buffer, x0: u16, y: u16, width: u16, palette: Option<Palette>) {
237    for x in x0..x0 + width {
238        let cell = &mut buf[(x, y)];
239        match palette {
240            Some(p) => {
241                cell.set_bg(p.focus_bg());
242            }
243            None => {
244                cell.set_style(Style::default().add_modifier(Modifier::REVERSED));
245            }
246        }
247    }
248}
249
250/// Split `path` into spans, styling the matched character positions. `indices`
251/// are char offsets, sorted and deduplicated by the picker.
252fn highlight_spans(path: &str, indices: &[u32], match_style: Style) -> Vec<Span<'static>> {
253    let mut spans: Vec<Span<'static>> = Vec::new();
254    let mut run = String::new();
255    let mut run_matched = false;
256    for (i, chr) in path.chars().enumerate() {
257        let matched = indices.binary_search(&(i as u32)).is_ok();
258        if !run.is_empty() && matched != run_matched {
259            spans.push(span(std::mem::take(&mut run), run_matched, match_style));
260        }
261        run.push(chr);
262        run_matched = matched;
263    }
264    if !run.is_empty() {
265        spans.push(span(run, run_matched, match_style));
266    }
267    spans
268}
269
270fn span(text: String, matched: bool, match_style: Style) -> Span<'static> {
271    if matched {
272        Span::styled(text, match_style)
273    } else {
274        Span::raw(text)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::cli::ExpandSpec;
282    use crate::config::Config;
283    use crate::fstree;
284    use crate::tree::{ActionValues, Tree};
285    use ratatui::buffer::Buffer;
286
287    fn drawn(app: &mut App, width: u16, height: u16) -> (Buffer, String) {
288        let area = Rect::new(0, 0, width, height);
289        let mut buf = Buffer::empty(area);
290        draw(app, area, &mut buf);
291        let text: String = (0..height)
292            .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>() + "\n")
293            .collect();
294        (buf, text)
295    }
296
297    fn fixture_app() -> (tempfile::TempDir, App) {
298        let dir = tempfile::tempdir().unwrap();
299        std::fs::create_dir(dir.path().join("subdir")).unwrap();
300        std::fs::write(dir.path().join("subdir/inner.txt"), "").unwrap();
301        std::fs::write(dir.path().join("subdir/last.txt"), "").unwrap();
302        std::fs::write(dir.path().join("file.txt"), "").unwrap();
303        let tree = fstree::scan(dir.path(), false).unwrap();
304        let app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
305        (dir, app)
306    }
307
308    #[test]
309    fn tree_guides_have_no_horizontal_tails() {
310        let (_d, mut app) = fixture_app();
311        let (_buf, text) = drawn(&mut app, 40, 10);
312        assert!(
313            text.contains("├ • inner.txt"),
314            "expected `├ • inner.txt` in:\n{text}"
315        );
316        assert!(
317            text.contains("└ • last.txt"),
318            "expected `└ • last.txt` in:\n{text}"
319        );
320        assert!(!text.contains('─'), "no horizontal tails in:\n{text}");
321    }
322
323    #[test]
324    fn node_type_glyphs_follow_parent_stems_with_one_space() {
325        let mut tree = Tree::new();
326        let root = tree.push(None, "root", true, ActionValues::new("", "", ""));
327        let open = tree.push(Some(root), "open", true, ActionValues::new("", "", ""));
328        tree.push(Some(open), "nested", false, ActionValues::new("", "", ""));
329        let closed = tree.push(Some(root), "closed", true, ActionValues::new("", "", ""));
330        tree.push(Some(closed), "hidden", false, ActionValues::new("", "", ""));
331        tree.push(Some(root), "leaf", false, ActionValues::new("", "", ""));
332        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
333        app.state.set_expanded(closed, Some(root), false);
334
335        let (_buf, text) = drawn(&mut app, 40, 10);
336        let got: Vec<_> = text.lines().take(5).map(str::trim_end).collect();
337        assert_eq!(
338            got,
339            [
340                "▼ root",
341                "├ ▼ open",
342                "│ └ • nested",
343                "├ ▶ closed",
344                "└ • leaf",
345            ]
346        );
347    }
348
349    #[test]
350    fn top_level_leaves_use_the_leaf_glyph() {
351        let (_d, mut app) = fixture_app();
352        let (_buf, text) = drawn(&mut app, 40, 10);
353        let got: Vec<_> = text.lines().take(4).map(str::trim_end).collect();
354
355        assert_eq!(
356            got,
357            ["▼ subdir", "├ • inner.txt", "└ • last.txt", "• file.txt"]
358        );
359    }
360
361    #[test]
362    fn focus_bg_blends_foreground_at_ten_percent() {
363        let white_on_black = Palette {
364            fg: (255, 255, 255),
365            bg: (0, 0, 0),
366        };
367        assert_eq!(white_on_black.focus_bg(), Color::Rgb(26, 26, 26));
368        let mixed = Palette {
369            fg: (0, 0, 0),
370            bg: (200, 100, 50),
371        };
372        assert_eq!(mixed.focus_bg(), Color::Rgb(180, 90, 45));
373    }
374
375    #[test]
376    fn focused_row_uses_blended_bg_when_palette_known() {
377        let (_d, mut app) = fixture_app();
378        app.palette = Some(Palette {
379            fg: (255, 255, 255),
380            bg: (0, 0, 0),
381        });
382        let (buf, text) = drawn(&mut app, 40, 10);
383        // Focus starts on the first row ("subdir").
384        assert!(text.starts_with("▼ subdir"), "{text}");
385        let cell = &buf[(0, 0)];
386        assert_eq!(cell.bg, Color::Rgb(26, 26, 26), "focused bg is the blend");
387        assert!(
388            !cell.modifier.contains(Modifier::REVERSED),
389            "no reverse video when the palette is known"
390        );
391    }
392
393    #[test]
394    fn focused_row_falls_back_to_reverse_video_without_palette() {
395        let (_d, mut app) = fixture_app();
396        assert_eq!(app.palette, None);
397        let (buf, text) = drawn(&mut app, 40, 10);
398        assert!(text.starts_with("▼ subdir"), "{text}");
399        assert!(
400            buf[(0, 0)].modifier.contains(Modifier::REVERSED),
401            "reverse video fallback"
402        );
403    }
404
405    #[test]
406    fn tree_chrome_uses_ansi_color_8() {
407        let mut tree = Tree::new();
408        let outer = tree.push(None, "outer", true, ActionValues::new("", "", ""));
409        let inner = tree.push(Some(outer), "inner", true, ActionValues::new("", "", ""));
410        tree.push(Some(inner), "first", false, ActionValues::new("", "", ""));
411        tree.push(Some(inner), "last", false, ActionValues::new("", "", ""));
412        tree.push(Some(outer), "sibling", false, ActionValues::new("", "", ""));
413        let closed = tree.push(None, "closed", true, ActionValues::new("", "", ""));
414        tree.push(Some(closed), "hidden", false, ActionValues::new("", "", ""));
415        tree.push(None, "root-leaf", false, ActionValues::new("", "", ""));
416        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
417        app.state.set_expanded(closed, None, false);
418
419        let (buf, text) = drawn(&mut app, 40, 10);
420        for (x, y, symbol) in [
421            (0, 0, "▼"),
422            (0, 1, "├"),
423            (2, 1, "▼"),
424            (0, 2, "│"),
425            (2, 2, "├"),
426            (4, 2, "•"),
427            (0, 3, "│"),
428            (2, 3, "└"),
429            (4, 3, "•"),
430            (0, 4, "└"),
431            (2, 4, "•"),
432            (0, 5, "▶"),
433            (0, 6, "•"),
434        ] {
435            let cell = &buf[(x, y)];
436            assert_eq!(
437                cell.symbol(),
438                symbol,
439                "unexpected tree at ({x}, {y}):\n{text}"
440            );
441            assert_eq!(
442                cell.fg,
443                Color::DarkGray,
444                "tree glyph at ({x}, {y}) should use ANSI foreground color 8"
445            );
446        }
447    }
448
449    #[test]
450    fn node_detail_uses_ansi_color_8_while_primary_text_stays_normal() {
451        let mut tree = Tree::new();
452        let root = tree.push_with_detail(
453            None,
454            "project {4}",
455            Some(r#"name: "ite" · status: "experimental""#.to_owned()),
456            true,
457            ActionValues::new("", "", ""),
458        );
459        tree.push(
460            Some(root),
461            r#"name: "ite""#,
462            false,
463            ActionValues::new("", "", ""),
464        );
465        let mut app = App::new(tree, &Config::default(), None);
466
467        let (buf, text) = drawn(&mut app, 60, 1);
468
469        assert!(
470            text.starts_with(r#"▶ project {4} name: "ite" · status: "experimental""#),
471            "{text}"
472        );
473        let primary = &buf[(2, 0)];
474        assert_eq!(primary.fg, Color::Reset);
475        assert!(!primary.modifier.contains(Modifier::BOLD));
476
477        let detail = &buf[(14, 0)];
478        assert_eq!(detail.symbol(), "n");
479        assert_eq!(detail.fg, Color::DarkGray);
480        assert!(!detail.modifier.contains(Modifier::BOLD));
481    }
482
483    #[test]
484    fn renders_expanded_tree_rows() {
485        let dir = tempfile::tempdir().unwrap();
486        std::fs::create_dir(dir.path().join("subdir")).unwrap();
487        std::fs::write(dir.path().join("subdir/inner.txt"), "").unwrap();
488        std::fs::write(dir.path().join("file.txt"), "").unwrap();
489        let tree = fstree::scan(dir.path(), false).unwrap();
490        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
491
492        let area = Rect::new(0, 0, 40, 10);
493        let mut buf = Buffer::empty(area);
494        draw(&mut app, area, &mut buf);
495
496        let text: String = (0..area.height)
497            .map(|y| {
498                (0..area.width)
499                    .map(|x| buf[(x, y)].symbol())
500                    .collect::<String>()
501                    + "\n"
502            })
503            .collect();
504        assert!(text.contains("subdir"), "missing subdir in:\n{text}");
505        assert!(text.contains("inner.txt"), "missing inner.txt in:\n{text}");
506        assert!(text.contains("file.txt"), "missing file.txt in:\n{text}");
507        assert_eq!(app.page_height, 10);
508    }
509
510    /// A child's stem (`├`/`└`/`│`) must sit in the same column as its
511    /// parent container's triangle. With single-column guides the widget's
512    /// disclosure glyph lands one column past the guides, so deeper stems
513    /// used to drift left of the triangle they hang from.
514    #[test]
515    fn stems_align_with_parent_triangle() {
516        let dir = tempfile::tempdir().unwrap();
517        std::fs::create_dir_all(dir.path().join("outer/inner")).unwrap();
518        std::fs::write(dir.path().join("outer/inner/deep.txt"), "").unwrap();
519        std::fs::write(dir.path().join("outer/inner/deep2.txt"), "").unwrap();
520        std::fs::write(dir.path().join("outer/sibling.txt"), "").unwrap();
521        std::fs::write(dir.path().join("zroot.txt"), "").unwrap();
522        let tree = fstree::scan(dir.path(), false).unwrap();
523        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
524        let (_buf, text) = drawn(&mut app, 40, 12);
525        let got: String = text
526            .lines()
527            .take(6)
528            .map(|l| format!("{}\n", l.trim_end()))
529            .collect();
530        let want = "\
531▼ outer
532├ ▼ inner
533│ ├ • deep.txt
534│ └ • deep2.txt
535└ • sibling.txt
536• zroot.txt
537";
538        assert_eq!(got, want, "\ngot:\n{got}\nwant:\n{want}");
539    }
540
541    /// Guards against the virtual-canvas regression: a mis-sized column made
542    /// the widget allocate and render a 65k-cell-wide buffer per frame
543    /// (~10ms). 100 draws must stay far under that regime's ~1s.
544    #[test]
545    fn repeated_draws_are_fast() {
546        let dir = tempfile::tempdir().unwrap();
547        for i in 0..30 {
548            std::fs::write(dir.path().join(format!("file-{i:02}.txt")), "").unwrap();
549        }
550        let tree = fstree::scan(dir.path(), false).unwrap();
551        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
552        let area = Rect::new(0, 0, 120, 40);
553        let mut buf = Buffer::empty(area);
554        draw(&mut app, area, &mut buf); // warm-up
555        let start = std::time::Instant::now();
556        for _ in 0..100 {
557            draw(&mut app, area, &mut buf);
558        }
559        let elapsed = start.elapsed();
560        assert!(
561            elapsed < std::time::Duration::from_millis(500),
562            "100 draws took {elapsed:?}"
563        );
564    }
565
566    #[test]
567    fn jump_picker_renders_prompt_results_and_highlights() {
568        use crate::keys::Key;
569        let (_d, mut app) = fixture_app();
570        app.handle_key(Key::parse("/").unwrap());
571        for k in ["i", "n", "n", "e", "r"] {
572            app.handle_key(Key::parse(k).unwrap());
573        }
574        let (buf, text) = drawn(&mut app, 40, 10);
575        let lines: Vec<&str> = text.lines().collect();
576        // A dim `/ ` prefix (same color as the counter), then the query.
577        assert!(
578            lines[0].starts_with("/ inner"),
579            "prompt row: {:?}",
580            lines[0]
581        );
582        assert_eq!(buf[(0, 0)].symbol(), "/");
583        assert_eq!(buf[(0, 0)].fg, Color::DarkGray);
584        // A block cursor (reverse video) sits at the caret, just past the query.
585        assert!(
586            buf[(7, 0)].modifier.contains(Modifier::REVERSED),
587            "expected a block cursor after `/ inner`"
588        );
589        // The counter shows one match out of the four candidate nodes.
590        assert!(
591            lines[0].trim_end().ends_with("1/4"),
592            "counter row: {:?}",
593            lines[0]
594        );
595        // Row 1 is a full-width divider under the input.
596        assert_eq!(lines[1], "─".repeat(40), "divider row: {:?}", lines[1]);
597        // Results begin on row 2.
598        assert!(lines[2].contains("inner.txt"), "result row: {:?}", lines[2]);
599        // At least one matched character in the results renders cyan + bold.
600        let highlighted = (0..40).any(|x| {
601            (2..10).any(|y| {
602                let cell = &buf[(x, y)];
603                cell.fg == Color::Cyan && cell.modifier.contains(Modifier::BOLD)
604            })
605        });
606        assert!(highlighted, "expected a highlighted match cell:\n{text}");
607    }
608}