1use 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#[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
64pub 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 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
148pub 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.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
240fn 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
275pub 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}