1use 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 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
70const 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#[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 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
113fn 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 horizontal_scroll: tui_treelistview::TreeHorizontalScroll::Disabled,
128 ..TreeListViewStyle::borderless()
129 }
130}
131
132pub 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
155fn jump_area(screen: Rect) -> Rect {
160 screen
161}
162
163pub 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 let rows = area.height.saturating_sub(2) as usize;
174 jump.set_viewport(rows);
175
176 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 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 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 if area.height >= 2 {
203 let divider = "─".repeat(width as usize);
204 buf.set_stringn(
205 area.x,
206 area.y + 1,
207 ÷r,
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
234fn 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
250fn 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 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 #[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 #[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); 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 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 assert!(
586 buf[(7, 0)].modifier.contains(Modifier::REVERSED),
587 "expected a block cursor after `/ inner`"
588 );
589 assert!(
591 lines[0].trim_end().ends_with("1/4"),
592 "counter row: {:?}",
593 lines[0]
594 );
595 assert_eq!(lines[1], "─".repeat(40), "divider row: {:?}", lines[1]);
597 assert!(lines[2].contains("inner.txt"), "result row: {:?}", lines[2]);
599 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}