Skip to main content

limit_tui/components/
file_autocomplete.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::Widget,
7};
8
9/// File match result from FileFinder
10#[derive(Debug, Clone)]
11pub struct FileMatchData {
12    /// Relative path from working directory
13    pub path: String,
14    /// Whether it's a directory
15    pub is_dir: bool,
16}
17
18/// Widget for displaying file autocomplete suggestions
19pub struct FileAutocompleteWidget<'a> {
20    /// List of matching files
21    matches: &'a [FileMatchData],
22    /// Currently selected index
23    selected_index: usize,
24    /// Query string for highlighting
25    query: &'a str,
26}
27
28impl<'a> FileAutocompleteWidget<'a> {
29    /// Create a new file autocomplete widget
30    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    /// Highlight matching characters in the path
39    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        // Find all matches
51        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                // Add text before match
58                if abs_start > last_end {
59                    spans.push(Span::raw(path[last_end..abs_start].to_string()));
60                }
61
62                // Add matched text with highlight
63                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        // Add remaining text
78        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        // Draw border
93        let border_style = Style::default().fg(Color::Cyan);
94
95        // Top border
96        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        // Draw each match with scroll support
109        let max_items = (area.height.saturating_sub(2)) as usize; // -2 for borders
110
111        // Calculate scroll offset to keep selected item visible
112        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            // Build the line with selection indicator and path
136            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            // Add highlighted path
148            let mut path_spans = self.highlight_match(&file_match.path);
149            spans.append(&mut path_spans);
150
151            // Add directory indicator
152            if file_match.is_dir {
153                spans.push(Span::styled(
154                    "/",
155                    Style::default().fg(Color::Blue).bg(bg_color),
156                ));
157            }
158
159            // Calculate remaining space for padding
160            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        // Bottom border
186        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
209/// Calculate popup area for autocomplete widget
210pub 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; // +2 for borders
213
214    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); // 5 + 2 borders
257        assert_eq!(popup.y, 13); // 20 - 7
258    }
259}