Skip to main content

fret_ui_headless/
text_assist.rs

1//! Headless text-assist list controller for editor completion/history surfaces.
2//!
3//! This intentionally sits below any concrete UI:
4//! - query/filter/highlight math is deterministic and reusable,
5//! - active-item navigation skips disabled rows,
6//! - and callers remain free to render the result as a popup, inline history list, or command-like
7//!   palette while wiring focus/overlay semantics in `fret-ui-kit` / recipe crates.
8
9use 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}