limit_tui/components/
file_autocomplete.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Modifier, Style},
5 text::{Line, Span},
6 widgets::Widget,
7};
8
9#[derive(Debug, Clone)]
11pub struct FileMatchData {
12 pub path: String,
14 pub is_dir: bool,
16}
17
18pub struct FileAutocompleteWidget<'a> {
20 matches: &'a [FileMatchData],
22 selected_index: usize,
24 query: &'a str,
26}
27
28impl<'a> FileAutocompleteWidget<'a> {
29 pub fn new(matches: &'a [FileMatchData], selected_index: usize, query: &'a str) -> Self {
31 Self {
32 matches,
33 selected_index,
34 query,
35 }
36 }
37
38 fn highlight_match(&self, path: &str) -> Vec<Span<'static>> {
40 let query_lower = self.query.to_lowercase();
41 let path_lower = path.to_lowercase();
42
43 if self.query.is_empty() {
44 return vec![Span::raw(path.to_string())];
45 }
46
47 let mut spans = Vec::new();
48 let mut last_end = 0;
49
50 let mut pos = 0;
52 while pos < path.len() {
53 if let Some(start) = path_lower[pos..].find(&query_lower) {
54 let abs_start = pos + start;
55 let abs_end = abs_start + self.query.len();
56
57 if abs_start > last_end {
59 spans.push(Span::raw(path[last_end..abs_start].to_string()));
60 }
61
62 spans.push(Span::styled(
64 path[abs_start..abs_end.min(path.len())].to_string(),
65 Style::default()
66 .fg(Color::Yellow)
67 .add_modifier(Modifier::BOLD),
68 ));
69
70 last_end = abs_end.min(path.len());
71 pos = abs_end;
72 } else {
73 break;
74 }
75 }
76
77 if last_end < path.len() {
79 spans.push(Span::raw(path[last_end..].to_string()));
80 }
81
82 spans
83 }
84}
85
86impl<'a> Widget for FileAutocompleteWidget<'a> {
87 fn render(self, area: Rect, buf: &mut Buffer) {
88 if self.matches.is_empty() || area.height == 0 {
89 return;
90 }
91
92 let border_style = Style::default().fg(Color::Cyan);
94
95 if area.height > 0 {
97 let top_line = Line::default().spans(vec![
98 Span::styled("┌", border_style),
99 Span::styled(
100 "─".repeat(area.width.saturating_sub(2) as usize),
101 border_style,
102 ),
103 Span::styled("┐", border_style),
104 ]);
105 top_line.render(area, buf);
106 }
107
108 let max_items = (area.height.saturating_sub(2)) as usize; let scroll_offset = if self.selected_index >= max_items {
113 self.selected_index - max_items + 1
114 } else {
115 0
116 };
117
118 let items_to_show = self.matches.len().min(max_items);
119 let end_index = (scroll_offset + items_to_show).min(self.matches.len());
120
121 for (display_idx, file_match) in self.matches[scroll_offset..end_index].iter().enumerate() {
122 let actual_idx = scroll_offset + display_idx;
123 let y = area.y + 1 + display_idx as u16;
124 if y >= area.y + area.height - 1 {
125 break;
126 }
127
128 let is_selected = actual_idx == self.selected_index;
129 let bg_color = if is_selected {
130 Color::DarkGray
131 } else {
132 Color::Reset
133 };
134
135 let mut spans = vec![Span::styled("│", border_style)];
137
138 if is_selected {
139 spans.push(Span::styled(
140 "► ",
141 Style::default().fg(Color::Yellow).bg(bg_color),
142 ));
143 } else {
144 spans.push(Span::styled(" ", Style::default().bg(bg_color)));
145 }
146
147 let mut path_spans = self.highlight_match(&file_match.path);
149 spans.append(&mut path_spans);
150
151 if file_match.is_dir {
153 spans.push(Span::styled(
154 "/",
155 Style::default().fg(Color::Blue).bg(bg_color),
156 ));
157 }
158
159 let used_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
161 let padding = area
162 .width
163 .saturating_sub(used_width as u16)
164 .saturating_sub(1);
165
166 spans.push(Span::styled(
167 " ".repeat(padding as usize),
168 Style::default().bg(bg_color),
169 ));
170
171 spans.push(Span::styled("│", border_style));
172
173 let line = Line::default().spans(spans);
174 line.render(
175 Rect {
176 x: area.x,
177 y,
178 width: area.width,
179 height: 1,
180 },
181 buf,
182 );
183 }
184
185 if area.height > 1 {
187 let bottom_y = area.y + area.height - 1;
188 let bottom_line = Line::default().spans(vec![
189 Span::styled("└", border_style),
190 Span::styled(
191 "─".repeat(area.width.saturating_sub(2) as usize),
192 border_style,
193 ),
194 Span::styled("┘", border_style),
195 ]);
196 bottom_line.render(
197 Rect {
198 x: area.x,
199 y: bottom_y,
200 width: area.width,
201 height: 1,
202 },
203 buf,
204 );
205 }
206 }
207}
208
209pub fn calculate_popup_area(input_area: Rect, match_count: usize) -> Rect {
211 let max_height = 10;
212 let height = (match_count + 2).min(max_height as usize) as u16; Rect {
215 x: input_area.x,
216 y: input_area.y.saturating_sub(height),
217 width: input_area.width,
218 height,
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn test_highlight_match_basic() {
228 let matches = vec![FileMatchData {
229 path: "Cargo.toml".to_string(),
230 is_dir: false,
231 }];
232 let widget = FileAutocompleteWidget::new(&matches, 0, "Cargo");
233 let spans = widget.highlight_match("Cargo.toml");
234
235 assert!(!spans.is_empty());
236 }
237
238 #[test]
239 fn test_highlight_match_empty_query() {
240 let matches = vec![FileMatchData {
241 path: "Cargo.toml".to_string(),
242 is_dir: false,
243 }];
244 let widget = FileAutocompleteWidget::new(&matches, 0, "");
245 let spans = widget.highlight_match("Cargo.toml");
246
247 assert_eq!(spans.len(), 1);
248 assert_eq!(spans[0].content, "Cargo.toml");
249 }
250
251 #[test]
252 fn test_calculate_popup_area() {
253 let input_area = Rect::new(0, 20, 80, 3);
254 let popup = calculate_popup_area(input_area, 5);
255
256 assert_eq!(popup.height, 7); assert_eq!(popup.y, 13); }
259}