mermaid_cli/render/widgets/
file_picker.rs1use ratatui::{
12 buffer::Buffer,
13 layout::Rect,
14 style::{Modifier, Style},
15 text::{Line, Span},
16 widgets::{Block, Borders, Paragraph, Widget},
17};
18
19use crate::render::theme::Theme;
20
21const MAX_VISIBLE_ROWS: usize = 8;
23
24pub struct FilePickerWidget<'a> {
25 pub theme: &'a Theme,
26 pub matches: &'a [String],
28 pub selected_index: usize,
30 pub loading: bool,
32}
33
34impl<'a> Widget for FilePickerWidget<'a> {
35 fn render(self, area: Rect, buf: &mut Buffer) {
36 let total = self.matches.len();
39 let selected = self.selected_index.min(total.saturating_sub(1));
40 let scroll_offset = if selected >= MAX_VISIBLE_ROWS {
41 selected + 1 - MAX_VISIBLE_ROWS
42 } else {
43 0
44 };
45 let visible_end = (scroll_offset + MAX_VISIBLE_ROWS).min(total);
46
47 let title = if total > MAX_VISIBLE_ROWS {
48 format!(
49 " Files ({}-{} of {}) ↑↓ navigate · Tab/Enter insert · Esc dismiss ",
50 scroll_offset + 1,
51 visible_end,
52 total
53 )
54 } else {
55 format!(" Files ({total}) ↑↓ navigate · Tab/Enter insert · Esc dismiss ")
56 };
57 let block = Block::default()
58 .borders(Borders::ALL)
59 .border_style(Style::new().fg(self.theme.colors.border.to_color()))
60 .title(title);
61
62 if self.matches.is_empty() {
63 let text = if self.loading {
64 " Scanning project files..."
65 } else {
66 " No matching files"
67 };
68 let line = Line::from(vec![Span::styled(
69 text,
70 Style::new().fg(self.theme.colors.text_disabled.to_color()),
71 )]);
72 Paragraph::new(vec![line]).block(block).render(area, buf);
73 return;
74 }
75
76 let mut lines: Vec<Line> = Vec::with_capacity(MAX_VISIBLE_ROWS);
77 for (offset, path) in self.matches[scroll_offset..visible_end].iter().enumerate() {
78 let absolute_index = scroll_offset + offset;
79 let is_selected = absolute_index == selected;
80
81 let (dir, name) = match path.rfind('/') {
84 Some(_) if path.ends_with('/') => ("", path.as_str()),
86 Some(idx) => path.split_at(idx + 1),
87 None => ("", path.as_str()),
88 };
89 let dir_style = if is_selected {
90 Style::new()
91 .fg(self.theme.colors.text_secondary.to_color())
92 .add_modifier(Modifier::REVERSED)
93 } else {
94 Style::new().fg(self.theme.colors.text_secondary.to_color())
95 };
96 let name_style = if is_selected {
97 Style::new()
98 .fg(self.theme.colors.text_highlight.to_color())
99 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
100 } else {
101 Style::new().fg(self.theme.colors.text_primary.to_color())
102 };
103 lines.push(Line::from(vec![
104 Span::styled(" ", dir_style),
105 Span::styled(dir.to_string(), dir_style),
106 Span::styled(name.to_string(), name_style),
107 ]));
108 }
109 Paragraph::new(lines).block(block).render(area, buf);
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 fn render_lines(widget: FilePickerWidget<'_>, width: u16, height: u16) -> Vec<String> {
118 let area = Rect::new(0, 0, width, height);
119 let mut buf = Buffer::empty(area);
120 widget.render(area, &mut buf);
121 (0..height)
122 .map(|y| {
123 (0..width)
124 .map(|x| buf[(x, y)].symbol().to_string())
125 .collect()
126 })
127 .collect()
128 }
129
130 #[test]
131 fn renders_matches_with_count_in_title() {
132 let theme = Theme::dark();
133 let matches = vec!["src/main.rs".to_string(), "docs/".to_string()];
134 let lines = render_lines(
135 FilePickerWidget {
136 theme: &theme,
137 matches: &matches,
138 selected_index: 0,
139 loading: false,
140 },
141 60,
142 5,
143 );
144 let all = lines.join("\n");
145 assert!(all.contains("Files (2)"), "{all}");
146 assert!(all.contains("main.rs"), "{all}");
147 assert!(all.contains("docs/"), "{all}");
148 }
149
150 #[test]
151 fn out_of_range_selection_does_not_panic() {
152 let theme = Theme::dark();
153 let matches = vec!["a.rs".to_string()];
154 let lines = render_lines(
155 FilePickerWidget {
156 theme: &theme,
157 matches: &matches,
158 selected_index: 99,
159 loading: false,
160 },
161 40,
162 4,
163 );
164 assert!(lines.join("\n").contains("a.rs"));
165 }
166
167 #[test]
168 fn empty_states_distinguish_loading_from_no_match() {
169 let theme = Theme::dark();
170 let loading = render_lines(
171 FilePickerWidget {
172 theme: &theme,
173 matches: &[],
174 selected_index: 0,
175 loading: true,
176 },
177 50,
178 3,
179 )
180 .join("\n");
181 assert!(loading.contains("Scanning project files"), "{loading}");
182 let empty = render_lines(
183 FilePickerWidget {
184 theme: &theme,
185 matches: &[],
186 selected_index: 0,
187 loading: false,
188 },
189 50,
190 3,
191 )
192 .join("\n");
193 assert!(empty.contains("No matching files"), "{empty}");
194 }
195}