1use std::sync::Arc;
10
11use crate::{cmdk_score, cmdk_selection};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TextAssistItem {
15 pub id: Arc<str>,
16 pub label: Arc<str>,
17 pub aliases: Arc<[Arc<str>]>,
18 pub disabled: bool,
19}
20
21impl TextAssistItem {
22 pub fn new(id: impl Into<Arc<str>>, label: impl Into<Arc<str>>) -> Self {
23 Self {
24 id: id.into(),
25 label: label.into(),
26 aliases: Arc::from([]),
27 disabled: false,
28 }
29 }
30
31 pub fn aliases(mut self, aliases: impl Into<Arc<[Arc<str>]>>) -> Self {
32 self.aliases = aliases.into();
33 self
34 }
35
36 pub fn disabled(mut self, disabled: bool) -> Self {
37 self.disabled = disabled;
38 self
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum TextAssistMatchMode {
44 #[default]
45 Prefix,
46 CmdkFuzzy,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum TextAssistMove {
51 Next,
52 Previous,
53 First,
54 Last,
55 PageDown { amount: usize },
56 PageUp { amount: usize },
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub struct TextAssistMatch {
61 pub item_id: Arc<str>,
62 pub label: Arc<str>,
63 pub score: f32,
64 pub source_index: usize,
65 pub disabled: bool,
66}
67
68#[derive(Debug, Clone, Default)]
69pub struct TextAssistController {
70 mode: TextAssistMatchMode,
71 wrap_navigation: bool,
72 active_item_id: Option<Arc<str>>,
73 visible: Vec<TextAssistMatch>,
74}
75
76impl TextAssistController {
77 pub fn new(mode: TextAssistMatchMode) -> Self {
78 Self {
79 mode,
80 wrap_navigation: false,
81 active_item_id: None,
82 visible: Vec::new(),
83 }
84 }
85
86 pub fn with_wrap_navigation(mut self, wrap_navigation: bool) -> Self {
87 self.wrap_navigation = wrap_navigation;
88 self
89 }
90
91 pub fn mode(&self) -> TextAssistMatchMode {
92 self.mode
93 }
94
95 pub fn visible(&self) -> &[TextAssistMatch] {
96 &self.visible
97 }
98
99 pub fn active_item_id(&self) -> Option<&Arc<str>> {
100 self.active_item_id.as_ref()
101 }
102
103 pub fn active_match(&self) -> Option<&TextAssistMatch> {
104 let active = self.active_item_id.as_ref()?;
105 self.visible.iter().find(|entry| &entry.item_id == active)
106 }
107
108 pub fn rebuild(&mut self, items: &[TextAssistItem], query: &str) {
109 self.visible = build_visible_matches(items, query, self.mode);
110 self.active_item_id = resolve_active_item_id(&self.visible, self.active_item_id.as_deref());
111 }
112
113 pub fn set_active_item_id(&mut self, item_id: Option<impl Into<Arc<str>>>) {
114 let next = item_id.map(Into::into);
115 self.active_item_id = resolve_active_item_id(&self.visible, next.as_deref());
116 }
117
118 pub fn move_active(&mut self, movement: TextAssistMove) {
119 let disabled: Vec<bool> = self.visible.iter().map(|entry| entry.disabled).collect();
120 let current = self.active_match().map(|entry| {
121 self.visible
122 .iter()
123 .position(|candidate| candidate.item_id == entry.item_id)
124 .expect("active match must exist in visible list")
125 });
126
127 let next = match movement {
128 TextAssistMove::Next => {
129 cmdk_selection::next_active_index(&disabled, current, true, self.wrap_navigation)
130 }
131 TextAssistMove::Previous => {
132 cmdk_selection::next_active_index(&disabled, current, false, self.wrap_navigation)
133 }
134 TextAssistMove::First => cmdk_selection::first_enabled(&disabled),
135 TextAssistMove::Last => cmdk_selection::last_enabled(&disabled),
136 TextAssistMove::PageDown { amount } => cmdk_selection::advance_active_index(
137 &disabled,
138 current,
139 true,
140 self.wrap_navigation,
141 amount.max(1),
142 ),
143 TextAssistMove::PageUp { amount } => cmdk_selection::advance_active_index(
144 &disabled,
145 current,
146 false,
147 self.wrap_navigation,
148 amount.max(1),
149 ),
150 };
151
152 self.active_item_id =
153 next.and_then(|idx| self.visible.get(idx).map(|entry| entry.item_id.clone()));
154 }
155}
156
157pub fn build_visible_matches(
158 items: &[TextAssistItem],
159 query: &str,
160 mode: TextAssistMatchMode,
161) -> Vec<TextAssistMatch> {
162 let query = query.trim();
163 let mut out: Vec<TextAssistMatch> = items
164 .iter()
165 .enumerate()
166 .filter_map(|(source_index, item)| {
167 score_item(item, query, mode).map(|score| TextAssistMatch {
168 item_id: item.id.clone(),
169 label: item.label.clone(),
170 score,
171 source_index,
172 disabled: item.disabled,
173 })
174 })
175 .collect();
176
177 if matches!(mode, TextAssistMatchMode::CmdkFuzzy) && !query.is_empty() {
178 out.sort_by(|a, b| {
179 b.score
180 .total_cmp(&a.score)
181 .then_with(|| a.label.as_ref().cmp(b.label.as_ref()))
182 .then_with(|| a.source_index.cmp(&b.source_index))
183 });
184 }
185
186 out
187}
188
189fn score_item(item: &TextAssistItem, query: &str, mode: TextAssistMatchMode) -> Option<f32> {
190 if query.is_empty() {
191 return Some(1.0);
192 }
193
194 match mode {
195 TextAssistMatchMode::Prefix => prefix_matches(item, query).then_some(1.0),
196 TextAssistMatchMode::CmdkFuzzy => {
197 let aliases: Vec<&str> = item.aliases.iter().map(|alias| alias.as_ref()).collect();
198 let score = cmdk_score::command_score(item.label.as_ref(), query, &aliases);
199 (score > 0.0).then_some(score)
200 }
201 }
202}
203
204fn prefix_matches(item: &TextAssistItem, query: &str) -> bool {
205 if starts_with_case_folded(item.label.as_ref(), query) {
206 return true;
207 }
208
209 item.aliases
210 .iter()
211 .any(|alias| starts_with_case_folded(alias.as_ref(), query))
212}
213
214fn starts_with_case_folded(haystack: &str, needle: &str) -> bool {
215 haystack
216 .trim_start()
217 .to_ascii_lowercase()
218 .starts_with(&needle.to_ascii_lowercase())
219}
220
221fn resolve_active_item_id(visible: &[TextAssistMatch], current: Option<&str>) -> Option<Arc<str>> {
222 if let Some(current) = current
223 && visible
224 .iter()
225 .any(|entry| !entry.disabled && entry.item_id.as_ref() == current)
226 {
227 return Some(Arc::from(current));
228 }
229
230 visible
231 .iter()
232 .find(|entry| !entry.disabled)
233 .map(|entry| entry.item_id.clone())
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn sample_items() -> Vec<TextAssistItem> {
241 vec![
242 TextAssistItem::new("alpha", "Alpha"),
243 TextAssistItem::new("beta", "Beta").aliases(Arc::from([Arc::from("Second")])),
244 TextAssistItem::new("alpine", "Alpine").disabled(true),
245 TextAssistItem::new("gamma", "Gamma"),
246 ]
247 }
248
249 #[test]
250 fn prefix_mode_matches_labels_and_aliases_in_input_order() {
251 let items = sample_items();
252 let matches = build_visible_matches(&items, "se", TextAssistMatchMode::Prefix);
253
254 assert_eq!(matches.len(), 1);
255 assert_eq!(matches[0].item_id.as_ref(), "beta");
256 assert_eq!(matches[0].source_index, 1);
257 }
258
259 #[test]
260 fn fuzzy_mode_ranks_matches_by_score() {
261 let items = vec![
262 TextAssistItem::new("open-file", "Open File"),
263 TextAssistItem::new("open-folder", "Open Folder"),
264 TextAssistItem::new("close", "Close"),
265 ];
266
267 let matches = build_visible_matches(&items, "opf", TextAssistMatchMode::CmdkFuzzy);
268
269 assert_eq!(matches.len(), 2);
270 assert_eq!(matches[0].item_id.as_ref(), "open-file");
271 assert!(matches[0].score >= matches[1].score);
272 }
273
274 #[test]
275 fn rebuild_preserves_active_item_when_still_visible_and_enabled() {
276 let items = sample_items();
277 let mut controller = TextAssistController::new(TextAssistMatchMode::Prefix);
278 controller.rebuild(&items, "");
279 controller.set_active_item_id(Some(Arc::<str>::from("gamma")));
280
281 controller.rebuild(&items, "g");
282
283 assert_eq!(
284 controller.active_item_id().map(|id| id.as_ref()),
285 Some("gamma")
286 );
287 }
288
289 #[test]
290 fn rebuild_clamps_active_item_when_previous_match_disappears() {
291 let items = sample_items();
292 let mut controller = TextAssistController::new(TextAssistMatchMode::Prefix);
293 controller.rebuild(&items, "");
294 controller.set_active_item_id(Some(Arc::<str>::from("beta")));
295
296 controller.rebuild(&items, "ga");
297
298 assert_eq!(
299 controller.active_item_id().map(|id| id.as_ref()),
300 Some("gamma")
301 );
302 }
303
304 #[test]
305 fn navigation_skips_disabled_entries() {
306 let items = sample_items();
307 let mut controller = TextAssistController::new(TextAssistMatchMode::Prefix);
308 controller.rebuild(&items, "a");
309
310 assert_eq!(
311 controller.active_item_id().map(|id| id.as_ref()),
312 Some("alpha")
313 );
314
315 controller.move_active(TextAssistMove::Next);
316
317 assert_eq!(
318 controller.active_item_id(),
319 Some(&Arc::<str>::from("alpha"))
320 );
321 }
322
323 #[test]
324 fn page_navigation_uses_headless_selection_math() {
325 let items = vec![
326 TextAssistItem::new("a", "Alpha"),
327 TextAssistItem::new("b", "Beta"),
328 TextAssistItem::new("c", "Gamma"),
329 TextAssistItem::new("d", "Delta"),
330 ];
331 let mut controller =
332 TextAssistController::new(TextAssistMatchMode::Prefix).with_wrap_navigation(true);
333 controller.rebuild(&items, "");
334
335 controller.move_active(TextAssistMove::PageDown { amount: 2 });
336
337 assert_eq!(controller.active_item_id().map(|id| id.as_ref()), Some("c"));
338
339 controller.move_active(TextAssistMove::PageUp { amount: 1 });
340
341 assert_eq!(controller.active_item_id().map(|id| id.as_ref()), Some("b"));
342 }
343}