1use 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 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
76const 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#[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 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
119fn 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 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
141fn 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
170fn 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
185fn 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 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) .saturating_sub(2) .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 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, 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 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 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 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
329pub fn draw(app: &mut App, area: Rect, buf: &mut Buffer) {
332 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
376fn jump_area(screen: Rect) -> Rect {
381 screen
382}
383
384pub 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 let rows = area.height.saturating_sub(2) as usize;
395 jump.set_viewport(rows);
396
397 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 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 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 if area.height >= 2 {
424 let divider = "─".repeat(width as usize);
425 buf.set_stringn(
426 area.x,
427 area.y + 1,
428 ÷r,
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
455fn 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
471fn 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 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 #[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 #[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 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 #[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 #[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 #[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); 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 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 assert!(
962 buf[(7, 0)].modifier.contains(Modifier::REVERSED),
963 "expected a block cursor after `/ inner`"
964 );
965 assert!(
967 lines[0].trim_end().ends_with("1/4"),
968 "counter row: {:?}",
969 lines[0]
970 );
971 assert_eq!(lines[1], "─".repeat(40), "divider row: {:?}", lines[1]);
973 assert!(lines[2].contains("inner.txt"), "result row: {:?}", lines[2]);
975 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}