Skip to main content

heatsync_core/
emote.rs

1//! emote resolution. mirrors the server: /api/channel/:c/emotes returns a
2//! precedence-ordered (7tv > bttv > ffz > twitch > kick) deduped list, so we
3//! insert first-wins into a name→emote map and word-match message text against
4//! it. modifiers (BTTV `w!`-style prefixes / FFZ `ffzX`-style effect words) are
5//! parsed here into per-stack [`Effect`] lists; the pixel work happens at render.
6
7use std::collections::HashMap;
8
9use serde::Deserialize;
10
11/// one emote as the HeatSync emote API returns it.
12#[derive(Clone, Debug, Deserialize)]
13pub struct Emote {
14    pub name: String,
15    pub url: String,
16    pub provider: String,
17    #[serde(default)]
18    pub id: String,
19    #[serde(default)]
20    pub animated: bool,
21    #[serde(rename = "zeroWidth", default)]
22    pub zero_width: bool,
23}
24
25/// the /api/channel/:c/emotes envelope (only the bits we need).
26#[derive(Debug, Deserialize)]
27pub struct EmoteSetResponse {
28    #[serde(default)]
29    pub emotes: Vec<Emote>,
30}
31
32/// name → emote lookup, precedence-respecting (first insert wins).
33#[derive(Clone, Debug, Default)]
34pub struct EmoteSet {
35    map: HashMap<String, Emote>,
36}
37
38impl EmoteSet {
39    pub fn new() -> EmoteSet {
40        EmoteSet::default()
41    }
42
43    /// build from an API list. the server already ordered by precedence and
44    /// deduped, but we defend with first-wins so a stray dup can't override.
45    pub fn from_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
46        let mut set = EmoteSet::new();
47        for e in list {
48            set.map.entry(e.name.clone()).or_insert(e);
49        }
50        set
51    }
52
53    pub fn get(&self, name: &str) -> Option<&Emote> {
54        self.map.get(name)
55    }
56
57    /// all emote names (unordered) — completion feeds from this.
58    pub fn names(&self) -> impl Iterator<Item = &str> {
59        self.map.keys().map(String::as_str)
60    }
61
62    pub fn len(&self) -> usize {
63        self.map.len()
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.map.is_empty()
68    }
69}
70
71/// a pixel effect on an emote stack. BTTV prefix modifiers and FFZ effect words
72/// share this vocabulary; the render layer applies them in enum order (geometry
73/// → color → motion), so derived Ord IS the application order.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
75pub enum Effect {
76    RotateL, // l!
77    RotateR, // r!
78    FlipX,   // h! / ffzX
79    FlipY,   // v! / ffzY
80    Wide,    // w! / ffzW — stack renders double-width
81    Cursed,  // c! / ffzCursed — grayscale + darken
82    Rainbow, // p! / ffzRainbow — hue cycle (animates even static emotes)
83    Shake,   // s! / ffzHyper — jitter (animates even static emotes)
84}
85
86impl Effect {
87    /// one-letter code used in stack keys (stable — keys are cache identities).
88    pub fn code(self) -> char {
89        match self {
90            Effect::RotateL => 'l',
91            Effect::RotateR => 'r',
92            Effect::FlipX => 'x',
93            Effect::FlipY => 'y',
94            Effect::Wide => 'w',
95            Effect::Cursed => 'c',
96            Effect::Rainbow => 'p',
97            Effect::Shake => 's',
98        }
99    }
100
101    fn from_code(c: char) -> Option<Effect> {
102        Some(match c {
103            'l' => Effect::RotateL,
104            'r' => Effect::RotateR,
105            'x' => Effect::FlipX,
106            'y' => Effect::FlipY,
107            'w' => Effect::Wide,
108            'c' => Effect::Cursed,
109            'p' => Effect::Rainbow,
110            's' => Effect::Shake,
111            _ => return None,
112        })
113    }
114
115    /// BTTV `X!` prefix letter → effect. `z` (zero-width) is handled separately —
116    /// it changes stacking, not pixels.
117    fn from_bttv(c: char) -> Option<Effect> {
118        Some(match c {
119            'w' => Effect::Wide,
120            'h' => Effect::FlipX,
121            'v' => Effect::FlipY,
122            'c' => Effect::Cursed,
123            'l' => Effect::RotateL,
124            'r' => Effect::RotateR,
125            'p' => Effect::Rainbow,
126            's' => Effect::Shake,
127            _ => return None,
128        })
129    }
130
131    /// FFZ effect word (typed AFTER the emote it modifies) → effect.
132    fn from_ffz(word: &str) -> Option<Effect> {
133        Some(match word {
134            "ffzX" => Effect::FlipX,
135            "ffzY" => Effect::FlipY,
136            "ffzW" => Effect::Wide,
137            "ffzCursed" => Effect::Cursed,
138            "ffzRainbow" => Effect::Rainbow,
139            "ffzHyper" => Effect::Shake,
140            _ => return None,
141        })
142    }
143}
144
145/// a renderable emote stack: base emote + zero-width overlays + effects. the
146/// key doubles as the render cache identity — layer urls joined by '\n', with
147/// effects (if any) as a final `#codes` pseudo-layer ('\n' and '#'-first lines
148/// can't appear in a real url, so the parse back is unambiguous).
149#[derive(Clone, Debug, PartialEq)]
150pub struct Stack {
151    pub base: String, // base emote name — the text fallback while loading
152    pub urls: Vec<String>,
153    pub fx: Vec<Effect>, // sorted + deduped (stable keys)
154}
155
156impl Stack {
157    fn new(base: &str, url: String) -> Stack {
158        Stack {
159            base: base.to_string(),
160            urls: vec![url],
161            fx: Vec::new(),
162        }
163    }
164
165    fn add_fx(&mut self, fx: Effect) {
166        if let Err(i) = self.fx.binary_search(&fx) {
167            self.fx.insert(i, fx);
168        }
169    }
170
171    pub fn wide(&self) -> bool {
172        self.fx.contains(&Effect::Wide)
173    }
174
175    pub fn key(&self) -> String {
176        let mut k = self.urls.join("\n");
177        if !self.fx.is_empty() {
178            k.push_str("\n#");
179            k.extend(self.fx.iter().map(|f| f.code()));
180        }
181        k
182    }
183}
184
185/// split a stack key back into layer urls + effects (render side).
186pub fn split_key(key: &str) -> (Vec<&str>, Vec<Effect>) {
187    let mut urls = Vec::new();
188    let mut fx = Vec::new();
189    for line in key.split('\n') {
190        if let Some(codes) = line.strip_prefix('#') {
191            fx.extend(codes.chars().filter_map(Effect::from_code));
192        } else {
193            urls.push(line);
194        }
195    }
196    (urls, fx)
197}
198
199/// a laid-out chunk of a message: literal text, or an emote stack.
200#[derive(Clone, Debug, PartialEq)]
201pub enum Segment {
202    Text(String),
203    Stack(Stack),
204}
205
206/// strip chained BTTV prefixes (`w!h!Emote`) off a word. returns the collected
207/// effects, whether `z!` (force zero-width) was present, and the remainder.
208fn strip_bttv(word: &str) -> (Vec<Effect>, bool, &str) {
209    let mut fx = Vec::new();
210    let mut zw = false;
211    let mut rest = word;
212    loop {
213        let mut it = rest.chars();
214        match (it.next(), it.next()) {
215            (Some(c), Some('!')) if c == 'z' || Effect::from_bttv(c).is_some() => {
216                if c == 'z' {
217                    zw = true;
218                } else {
219                    fx.push(Effect::from_bttv(c).unwrap());
220                }
221                rest = &rest[2..];
222            }
223            _ => return (fx, zw, rest),
224        }
225    }
226}
227
228/// parse message text into text + emote-stack segments. handles:
229///   - zero-width emotes overlaying the preceding base (`GAMBA notL`)
230///   - BTTV modifiers, bare (`w! KEKW`) and attached (`w!KEKW`, chains ok)
231///   - `z!` forcing any emote to overlay the preceding stack
232///   - FFZ effect words after the emote (`KEKW ffzX ffzRainbow`)
233///
234/// modifier words that never find an emote degrade to literal text. adjacent
235/// text words coalesce single-spaced. input is assumed already sanitized.
236///
237/// this is the grammar-aware successor to [`tokenize`]; the tui's current
238/// renderer still consumes the plain [`Token`] stream (no modifier grammar
239/// yet), so both live here until render adopts stacks/effects.
240pub fn segments(content: &str, set: &EmoteSet) -> Vec<Segment> {
241    let mut out: Vec<Segment> = Vec::new();
242    let mut text = String::new();
243    // bare modifiers waiting for their emote, with the literal words to restore
244    // if the next word turns out to be text.
245    let mut pending: Vec<(Vec<Effect>, bool, String)> = Vec::new();
246
247    fn push_text(text: &mut String, w: &str) {
248        if !text.is_empty() {
249            text.push(' ');
250        }
251        text.push_str(w);
252    }
253    fn flush_pending(text: &mut String, pending: &mut Vec<(Vec<Effect>, bool, String)>) {
254        for (_, _, w) in pending.drain(..) {
255            push_text(text, &w);
256        }
257    }
258    fn flush_text(out: &mut Vec<Segment>, text: &mut String) {
259        if !text.is_empty() {
260            out.push(Segment::Text(std::mem::take(text)));
261        }
262    }
263
264    for word in content.split_whitespace() {
265        // FFZ effect word: applies to the immediately-preceding stack (nothing
266        // may sit between them — no text, no unconsumed modifiers).
267        if let Some(fx) = Effect::from_ffz(word) {
268            if text.is_empty() && pending.is_empty() {
269                if let Some(Segment::Stack(s)) = out.last_mut() {
270                    s.add_fx(fx);
271                    continue;
272                }
273            }
274            flush_pending(&mut text, &mut pending);
275            push_text(&mut text, word);
276            continue;
277        }
278
279        // exact name wins over prefix parsing (an emote could be NAMED "h!x").
280        let (fx, zw, rest) = if set.get(word).is_some() {
281            (Vec::new(), false, word)
282        } else {
283            strip_bttv(word)
284        };
285        if rest.is_empty() && (!fx.is_empty() || zw) {
286            // bare modifier(s) — hold for the next emote.
287            pending.push((fx, zw, word.to_string()));
288            continue;
289        }
290        match set.get(rest) {
291            Some(e) => {
292                let mut all_fx = fx;
293                let mut force_zw = zw;
294                for (pfx, pzw, _) in pending.drain(..) {
295                    all_fx.extend(pfx);
296                    force_zw |= pzw;
297                }
298                let overlay = e.zero_width || force_zw;
299                // an overlay with NO effects of its own joins the previous
300                // stack; effects always start a fresh stack (they apply to a
301                // whole composite, and a per-layer op would corrupt the base).
302                if overlay && all_fx.is_empty() && text.is_empty() {
303                    if let Some(Segment::Stack(s)) = out.last_mut() {
304                        s.urls.push(e.url.clone());
305                        continue;
306                    }
307                }
308                flush_text(&mut out, &mut text);
309                let mut s = Stack::new(rest, e.url.clone());
310                for f in all_fx {
311                    s.add_fx(f);
312                }
313                out.push(Segment::Stack(s));
314            }
315            None => {
316                flush_pending(&mut text, &mut pending);
317                push_text(&mut text, word);
318            }
319        }
320    }
321    flush_pending(&mut text, &mut pending);
322    flush_text(&mut out, &mut text);
323    out
324}
325
326/// a rendered chunk of a message: literal text, or a resolved emote name.
327/// legacy surface — no modifier grammar. kept because the tui's current
328/// renderer (main.rs) lays out plain text/emote tokens; see [`segments`] for
329/// the modifier-aware replacement once render adopts stacks/effects.
330#[derive(Clone, Debug, PartialEq)]
331pub enum Token {
332    Text(String),
333    Emote(String), // emote name; resolve via EmoteSet::get for the url
334}
335
336/// split message text into text + emote tokens by whole-word match. adjacent
337/// text words coalesce (single-spaced). input is assumed already sanitized.
338pub fn tokenize(content: &str, set: &EmoteSet) -> Vec<Token> {
339    let mut out: Vec<Token> = Vec::new();
340    let mut text = String::new();
341    for word in content.split_whitespace() {
342        if set.get(word).is_some() {
343            if !text.is_empty() {
344                out.push(Token::Text(std::mem::take(&mut text)));
345            }
346            out.push(Token::Emote(word.to_string()));
347        } else {
348            if !text.is_empty() {
349                text.push(' ');
350            }
351            text.push_str(word);
352        }
353    }
354    if !text.is_empty() {
355        out.push(Token::Text(text));
356    }
357    out
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn e(name: &str) -> Emote {
365        Emote {
366            name: name.into(),
367            url: format!("https://cdn/{name}.webp"),
368            provider: "7tv".into(),
369            id: name.into(),
370            animated: false,
371            zero_width: false,
372        }
373    }
374
375    #[test]
376    fn first_insert_wins_on_collision() {
377        let set = EmoteSet::from_list([
378            Emote {
379                provider: "7tv".into(),
380                ..e("Kappa")
381            },
382            Emote {
383                provider: "bttv".into(),
384                ..e("Kappa")
385            },
386        ]);
387        assert_eq!(set.get("Kappa").unwrap().provider, "7tv");
388        assert_eq!(set.len(), 1);
389    }
390
391    #[test]
392    fn tokenizes_text_and_emotes() {
393        let set = EmoteSet::from_list([e("KEKW"), e("GAMBA")]);
394        let toks = tokenize("lol KEKW that GAMBA moment", &set);
395        assert_eq!(
396            toks,
397            vec![
398                Token::Text("lol".into()),
399                Token::Emote("KEKW".into()),
400                Token::Text("that".into()),
401                Token::Emote("GAMBA".into()),
402                Token::Text("moment".into()),
403            ]
404        );
405    }
406
407    #[test]
408    fn segments_text_and_emotes() {
409        let set = EmoteSet::from_list([e("KEKW"), e("GAMBA")]);
410        let segs = segments("lol KEKW that GAMBA moment", &set);
411        assert_eq!(
412            segs,
413            vec![
414                Segment::Text("lol".into()),
415                Segment::Stack(Stack::new("KEKW", "https://cdn/KEKW.webp".into())),
416                Segment::Text("that".into()),
417                Segment::Stack(Stack::new("GAMBA", "https://cdn/GAMBA.webp".into())),
418                Segment::Text("moment".into()),
419            ]
420        );
421    }
422
423    fn zw(name: &str) -> Emote {
424        Emote {
425            zero_width: true,
426            ..e(name)
427        }
428    }
429
430    fn stacks(msg: &str, set: &EmoteSet) -> Vec<Stack> {
431        segments(msg, set)
432            .into_iter()
433            .filter_map(|s| match s {
434                Segment::Stack(s) => Some(s),
435                _ => None,
436            })
437            .collect()
438    }
439
440    #[test]
441    fn zero_width_overlays_previous_base() {
442        let set = EmoteSet::from_list([e("GAMBA"), zw("notL")]);
443        let s = stacks("GAMBA notL", &set);
444        assert_eq!(s.len(), 1);
445        assert_eq!(
446            s[0].urls,
447            vec!["https://cdn/GAMBA.webp", "https://cdn/notL.webp"]
448        );
449        assert_eq!(s[0].key(), "https://cdn/GAMBA.webp\nhttps://cdn/notL.webp");
450    }
451
452    #[test]
453    fn bttv_attached_prefix_chains() {
454        let set = EmoteSet::from_list([e("KEKW")]);
455        let s = stacks("w!h!KEKW", &set);
456        assert_eq!(s[0].fx, vec![Effect::FlipX, Effect::Wide]); // sorted apply order
457        assert!(s[0].wide());
458        assert_eq!(s[0].key(), "https://cdn/KEKW.webp\n#xw");
459    }
460
461    #[test]
462    fn bttv_bare_prefix_applies_to_next_emote() {
463        let set = EmoteSet::from_list([e("KEKW")]);
464        let s = stacks("w! KEKW", &set);
465        assert_eq!(s[0].fx, vec![Effect::Wide]);
466    }
467
468    #[test]
469    fn unused_modifier_degrades_to_text() {
470        let set = EmoteSet::from_list([e("KEKW")]);
471        let segs = segments("w! nothing here", &set);
472        assert_eq!(segs, vec![Segment::Text("w! nothing here".into())]);
473    }
474
475    #[test]
476    fn z_forces_overlay() {
477        let set = EmoteSet::from_list([e("GAMBA"), e("KEKW")]);
478        let s = stacks("GAMBA z!KEKW", &set);
479        assert_eq!(s.len(), 1);
480        assert_eq!(s[0].urls.len(), 2);
481    }
482
483    #[test]
484    fn ffz_effect_words_attach_to_previous_stack() {
485        let set = EmoteSet::from_list([e("KEKW")]);
486        let s = stacks("KEKW ffzX ffzRainbow", &set);
487        assert_eq!(s[0].fx, vec![Effect::FlipX, Effect::Rainbow]);
488    }
489
490    #[test]
491    fn ffz_effect_needs_adjacency() {
492        let set = EmoteSet::from_list([e("KEKW")]);
493        let segs = segments("KEKW lol ffzX", &set);
494        assert_eq!(
495            segs,
496            vec![
497                Segment::Stack(Stack::new("KEKW", "https://cdn/KEKW.webp".into())),
498                Segment::Text("lol ffzX".into()),
499            ]
500        );
501    }
502
503    #[test]
504    fn exact_emote_name_beats_prefix_parse() {
505        let set = EmoteSet::from_list([e("h!i")]);
506        let s = stacks("h!i", &set);
507        assert_eq!(s[0].base, "h!i");
508        assert!(s[0].fx.is_empty());
509    }
510
511    #[test]
512    fn key_roundtrips_through_split_key() {
513        let set = EmoteSet::from_list([e("GAMBA"), zw("notL")]);
514        let s = stacks("w!GAMBA notL ffzCursed", &set);
515        let key = s[0].key();
516        let (urls, fx) = split_key(&key);
517        assert_eq!(
518            urls,
519            vec!["https://cdn/GAMBA.webp", "https://cdn/notL.webp"]
520        );
521        assert_eq!(fx, vec![Effect::Wide, Effect::Cursed]);
522    }
523
524    #[test]
525    fn parses_real_api_json() {
526        let json = r#"{"channel":"xqc","emotes":[
527            {"name":"GAMBA","url":"https://cdn.7tv.app/emote/x/1x.webp","provider":"7tv","id":"x","animated":true}
528        ],"count":1}"#;
529        let resp: EmoteSetResponse = serde_json::from_str(json).unwrap();
530        let set = EmoteSet::from_list(resp.emotes);
531        assert!(set.get("GAMBA").unwrap().animated);
532    }
533}