Skip to main content

heatsync_core/
complete.rs

1//! tab completion for the composer — emote names and usernames, twitch-style.
2//!
3//! pure text machinery: the face hands in the line, the cursor, and candidate
4//! iterators; this returns what span to replace with what. cycling state lives
5//! here so repeated tabs walk the match list; ANY other edit drops the state
6//! (the face clears it), which is what makes the walk feel like one gesture.
7//!
8//! `@word` completes usernames only (the `@` is kept); a bare word matches
9//! emote names first (case-insensitive prefix), then usernames — same order
10//! every chat client trains.
11
12/// a completion in progress: the matches for the word that was under the
13/// cursor, and which one currently occupies the line.
14#[derive(Debug)]
15pub struct Completion {
16    /// char index where the completed span begins.
17    start: usize,
18    /// chars the span currently occupies (the original word, then whichever
19    /// candidate was last inserted).
20    len: usize,
21    items: Vec<String>,
22    /// index of the item currently in the line; None until the first advance.
23    idx: Option<usize>,
24}
25
26impl Completion {
27    /// find the word ending at `cursor` and gather its matches. None when the
28    /// cursor doesn't end a word or nothing matches — the tab then does nothing.
29    pub fn build<'a>(
30        text: &str,
31        cursor: usize,
32        emotes: impl Iterator<Item = &'a str>,
33        users: impl Iterator<Item = &'a str>,
34    ) -> Option<Completion> {
35        let chars: Vec<char> = text.chars().collect();
36        let cursor = cursor.min(chars.len());
37        let start = chars[..cursor]
38            .iter()
39            .rposition(|c| c.is_whitespace())
40            .map(|i| i + 1)
41            .unwrap_or(0);
42        if start == cursor {
43            return None; // nothing typed to complete
44        }
45        let word: String = chars[start..cursor].iter().collect();
46        let (prefix, at) = match word.strip_prefix('@') {
47            Some(rest) => (rest, true),
48            None => (word.as_str(), false),
49        };
50        if prefix.is_empty() {
51            return None;
52        }
53
54        // byte-safe: get() refuses a slice that would split a multibyte char
55        // (an emote named "🅰x" must not panic the completer).
56        let matches = |c: &str| {
57            c.get(..prefix.len())
58                .is_some_and(|h| h.eq_ignore_ascii_case(prefix))
59        };
60        let mut items: Vec<String> = Vec::new();
61        let mut push = |s: String| {
62            if !items.iter().any(|i| i.eq_ignore_ascii_case(&s)) {
63                items.push(s);
64            }
65        };
66        if !at {
67            let mut hits: Vec<&str> = emotes.filter(|e| matches(e)).collect();
68            hits.sort_unstable_by_key(|a| a.to_ascii_lowercase());
69            for e in hits {
70                push(e.to_string());
71            }
72        }
73        let mut hits: Vec<&str> = users.filter(|u| matches(u)).collect();
74        hits.sort_unstable_by_key(|a| a.to_ascii_lowercase());
75        for u in hits {
76            push(if at { format!("@{u}") } else { u.to_string() });
77        }
78
79        (!items.is_empty()).then_some(Completion {
80            start,
81            len: cursor - start,
82            items,
83            idx: None,
84        })
85    }
86
87    /// step to the next (+1) or previous (-1) match. returns the span to
88    /// replace — `(lo, hi)` in char indices — and the replacement text; the
89    /// internal span tracks the replacement so the next advance swaps it out.
90    pub fn advance(&mut self, dir: i32) -> (usize, usize, &str) {
91        let n = self.items.len();
92        let next = match (self.idx, dir >= 0) {
93            (None, true) => 0,
94            (None, false) => n - 1,
95            (Some(i), true) => (i + 1) % n,
96            (Some(i), false) => (i + n - 1) % n,
97        };
98        self.idx = Some(next);
99        let (lo, hi) = (self.start, self.start + self.len);
100        self.len = self.items[next].chars().count();
101        (lo, hi, &self.items[next])
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn build(text: &str, cursor: usize) -> Option<Completion> {
110        let emotes = ["KEKW", "Kappa", "kekHeim", "GAMBA"];
111        let users = ["kekuser", "Gamba_Andy", "mellen"];
112        Completion::build(text, cursor, emotes.iter().copied(), users.iter().copied())
113    }
114
115    #[test]
116    fn completes_emotes_case_insensitively_then_users() {
117        let mut c = build("hi kek", 6).unwrap();
118        assert_eq!(c.advance(1), (3, 6, "kekHeim"));
119        assert_eq!(c.advance(1).2, "KEKW");
120        assert_eq!(c.advance(1).2, "kekuser");
121        assert_eq!(c.advance(1).2, "kekHeim", "wraps");
122    }
123
124    #[test]
125    fn advance_replaces_the_previous_completion_not_the_word() {
126        let mut c = build("kek", 3).unwrap();
127        let (lo, hi, s) = c.advance(1);
128        assert_eq!((lo, hi, s), (0, 3, "kekHeim"));
129        // the line now holds "kekHeim" — the next advance must span all 7 chars
130        let (lo, hi, s) = c.advance(1);
131        assert_eq!((lo, hi), (0, 7));
132        assert_eq!(s, "KEKW");
133    }
134
135    #[test]
136    fn backwards_starts_at_the_end_of_the_list() {
137        let mut c = build("kek", 3).unwrap();
138        assert_eq!(c.advance(-1).2, "kekuser");
139        assert_eq!(c.advance(-1).2, "KEKW");
140    }
141
142    #[test]
143    fn at_prefix_completes_users_only_and_keeps_the_at() {
144        let mut c = build("yo @ga", 6).unwrap();
145        assert_eq!(c.advance(1), (3, 6, "@Gamba_Andy"));
146        assert!(
147            build("yo @kekw", 8).is_none(),
148            "no user matches — emotes excluded"
149        );
150    }
151
152    #[test]
153    fn nothing_to_complete_yields_none() {
154        assert!(build("", 0).is_none());
155        assert!(build("word ", 5).is_none(), "cursor after a space");
156        assert!(build("zzz", 3).is_none(), "no matches");
157        assert!(build("@", 1).is_none(), "bare @");
158    }
159
160    #[test]
161    fn word_boundaries_are_char_safe() {
162        // multibyte text before the word must not break the span indices
163        let mut c = build("キツネ kek", 7).unwrap();
164        assert_eq!(c.advance(1), (4, 7, "kekHeim"));
165    }
166}