Skip to main content

oracle_lib/ui/
search.rs

1//! Search bar and completion widgets
2
3use fuzzy_matcher::skim::SkimMatcherV2;
4use fuzzy_matcher::FuzzyMatcher;
5use ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget},
11};
12
13use crate::ui::theme::Theme;
14
15/// A completion candidate
16#[derive(Debug, Clone)]
17pub struct CompletionCandidate {
18    pub primary: String,
19    pub secondary: Option<String>,
20    pub kind: CandidateKind,
21    pub score: i64,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CandidateKind {
26    Function,
27    Struct,
28    Enum,
29    Trait,
30    Module,
31    Type,
32    Const,
33    Crate,
34    Other,
35}
36
37impl CandidateKind {
38    pub fn icon(&self) -> &'static str {
39        match self {
40            CandidateKind::Function => "fn",
41            CandidateKind::Struct => "st",
42            CandidateKind::Enum => "en",
43            CandidateKind::Trait => "tr",
44            CandidateKind::Module => "md",
45            CandidateKind::Type => "ty",
46            CandidateKind::Const => "ct",
47            CandidateKind::Crate => "cr",
48            CandidateKind::Other => "  ",
49        }
50    }
51
52    pub fn color(&self, theme: &Theme) -> Color {
53        match self {
54            CandidateKind::Function => theme.function,
55            CandidateKind::Struct | CandidateKind::Enum | CandidateKind::Type => theme.type_,
56            CandidateKind::Trait => theme.keyword,
57            CandidateKind::Module | CandidateKind::Crate => theme.accent,
58            CandidateKind::Const => theme.number,
59            CandidateKind::Other => theme.fg_dim,
60        }
61    }
62}
63
64/// Search bar widget
65pub struct SearchBar<'a> {
66    input: &'a str,
67    cursor_pos: usize,
68    theme: &'a Theme,
69    focused: bool,
70    placeholder: &'a str,
71}
72
73impl<'a> SearchBar<'a> {
74    pub fn new(input: &'a str, theme: &'a Theme) -> Self {
75        Self {
76            input,
77            cursor_pos: input.len(),
78            theme,
79            focused: true,
80            placeholder: "Search...",
81        }
82    }
83
84    pub fn cursor_position(mut self, pos: usize) -> Self {
85        self.cursor_pos = pos;
86        self
87    }
88
89    pub fn focused(mut self, focused: bool) -> Self {
90        self.focused = focused;
91        self
92    }
93
94    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
95        self.placeholder = placeholder;
96        self
97    }
98}
99
100impl Widget for SearchBar<'_> {
101    fn render(self, area: Rect, buf: &mut Buffer) {
102        let border_style = if self.focused {
103            self.theme.style_border_focused()
104        } else {
105            self.theme.style_border()
106        };
107
108        let block = Block::default()
109            .borders(Borders::ALL)
110            .border_style(border_style)
111            .style(Style::default().bg(self.theme.bg_panel))
112            .title(" Search ");
113
114        let inner = block.inner(area);
115        block.render(area, buf);
116
117        // Render prompt
118        let prompt = Span::styled("❯ ", self.theme.style_accent_bold());
119
120        let (input_text, input_style) = if self.input.is_empty() {
121            (self.placeholder, self.theme.style_dim())
122        } else {
123            (self.input, self.theme.style_normal())
124        };
125
126        let cursor = if self.focused {
127            Span::styled(
128                "▏",
129                Style::default()
130                    .fg(self.theme.accent)
131                    .add_modifier(Modifier::SLOW_BLINK),
132            )
133        } else {
134            Span::raw("")
135        };
136
137        let line = Line::from(vec![
138            prompt,
139            Span::styled(input_text.to_string(), input_style),
140            cursor,
141        ]);
142
143        let paragraph = Paragraph::new(line);
144        paragraph.render(inner, buf);
145    }
146}
147
148/// Search completion dropdown
149pub struct SearchCompletion<'a> {
150    candidates: &'a [CompletionCandidate],
151    selected: usize,
152    filter: &'a str,
153    theme: &'a Theme,
154    max_visible: usize,
155}
156
157impl<'a> SearchCompletion<'a> {
158    pub fn new(candidates: &'a [CompletionCandidate], theme: &'a Theme) -> Self {
159        Self {
160            candidates,
161            selected: 0,
162            filter: "",
163            theme,
164            max_visible: 10,
165        }
166    }
167
168    pub fn selected(mut self, index: usize) -> Self {
169        self.selected = index;
170        self
171    }
172
173    pub fn filter(mut self, filter: &'a str) -> Self {
174        self.filter = filter;
175        self
176    }
177
178    pub fn max_visible(mut self, max: usize) -> Self {
179        self.max_visible = max;
180        self
181    }
182}
183
184impl Widget for SearchCompletion<'_> {
185    fn render(self, area: Rect, buf: &mut Buffer) {
186        if self.candidates.is_empty() {
187            return;
188        }
189
190        // Clear the area first
191        Clear.render(area, buf);
192
193        let visible_count = self.candidates.len().min(self.max_visible);
194
195        let items: Vec<ListItem> = self
196            .candidates
197            .iter()
198            .take(visible_count)
199            .enumerate()
200            .map(|(i, candidate)| {
201                let kind_span = Span::styled(
202                    format!("{} ", candidate.kind.icon()),
203                    Style::default().fg(candidate.kind.color(self.theme)),
204                );
205
206                let name = highlight_fuzzy(&candidate.primary, self.filter, self.theme);
207
208                let secondary = candidate
209                    .secondary
210                    .as_ref()
211                    .map(|s| Span::styled(format!(" {}", s), self.theme.style_muted()))
212                    .unwrap_or_else(|| Span::raw(""));
213
214                let mut spans = vec![kind_span];
215                spans.extend(name);
216                spans.push(secondary);
217
218                let line = Line::from(spans);
219                let style = if i == self.selected {
220                    self.theme.style_selected()
221                } else {
222                    Style::default()
223                };
224
225                ListItem::new(line).style(style)
226            })
227            .collect();
228
229        let list = List::new(items).block(
230            Block::default()
231                .borders(Borders::ALL)
232                .border_style(self.theme.style_border_focused())
233                .style(Style::default().bg(self.theme.bg_panel)),
234        );
235
236        list.render(area, buf);
237    }
238}
239
240/// Highlight matching characters in fuzzy search
241fn highlight_fuzzy<'a>(text: &'a str, query: &str, theme: &Theme) -> Vec<Span<'a>> {
242    if query.is_empty() {
243        return vec![Span::raw(text.to_string())];
244    }
245
246    let matcher = SkimMatcherV2::default();
247    if let Some((_, indices)) = matcher.fuzzy_indices(text, query) {
248        let mut spans = Vec::new();
249        let mut last_end = 0;
250
251        for &idx in &indices {
252            if idx > last_end {
253                spans.push(Span::raw(text[last_end..idx].to_string()));
254            }
255            spans.push(Span::styled(
256                text[idx..idx + 1].to_string(),
257                Style::default()
258                    .bg(theme.accent)
259                    .fg(Color::Black)
260                    .add_modifier(Modifier::BOLD),
261            ));
262            last_end = idx + 1;
263        }
264
265        if last_end < text.len() {
266            spans.push(Span::raw(text[last_end..].to_string()));
267        }
268
269        spans
270    } else {
271        vec![Span::raw(text.to_string())]
272    }
273}
274
275/// Filter and sort candidates based on fuzzy matching
276pub fn filter_candidates(
277    candidates: &[CompletionCandidate],
278    query: &str,
279) -> Vec<CompletionCandidate> {
280    if query.is_empty() {
281        return candidates.to_vec();
282    }
283
284    let matcher = SkimMatcherV2::default();
285    let mut scored: Vec<_> = candidates
286        .iter()
287        .filter_map(|c| {
288            matcher.fuzzy_match(&c.primary, query).map(|score| {
289                let mut candidate = c.clone();
290                candidate.score = score;
291                candidate
292            })
293        })
294        .collect();
295
296    scored.sort_by(|a, b| b.score.cmp(&a.score));
297    scored
298}