1use 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 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
87const GLYPHS: TreeGlyphs<'static> = TreeGlyphs {
97 indent: " ",
98 branch_last: "└",
99 branch: "├",
100 vert: "│ ",
101 empty: " ",
102 leaf: "•",
103 expanded: "▼",
104 collapsed: "▶",
105 unloaded: "▶",
109 loading: "◌",
110};
111
112#[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 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
133fn 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 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
155fn 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
184fn 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
199fn 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 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) .saturating_sub(2) .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 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, 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 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 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
353pub fn draw(app: &mut App, area: Rect, buf: &mut Buffer) {
356 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 if !app.tree.errors().is_empty() && tree_area.height > 0 {
390 let banner = format!(
391 "⚠ {} invalid record(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
425fn 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
445fn jump_area(screen: Rect) -> Rect {
450 screen
451}
452
453pub 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 let rows = area.height.saturating_sub(2) as usize;
464 jump.set_viewport(rows);
465
466 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 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 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 if area.height >= 2 {
493 let divider = "─".repeat(width as usize);
494 buf.set_stringn(
495 area.x,
496 area.y + 1,
497 ÷r,
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
524fn 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
540fn 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 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 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 #[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 #[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 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 #[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 #[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 #[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); 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 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 assert!(
1140 buf[(7, 0)].modifier.contains(Modifier::REVERSED),
1141 "expected a block cursor after `/ inner`"
1142 );
1143 assert!(
1145 lines[0].trim_end().ends_with("1/4"),
1146 "counter row: {:?}",
1147 lines[0]
1148 );
1149 assert_eq!(lines[1], "─".repeat(40), "divider row: {:?}", lines[1]);
1151 assert!(lines[2].contains("inner.txt"), "result row: {:?}", lines[2]);
1153 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}