onedrop_engine/
messages.rs

1//! Text overlay (`MILK_MSG.INI`) state and lifecycle.
2//!
3//! Holds the parsed message definitions ([`MessageDef`]) and the
4//! active list with per-instance lifecycle state. Each tick computes
5//! the current fade alpha for every active message and produces a
6//! list of [`MessageRenderInstance`]s the renderer turns into glyph
7//! quads.
8//!
9//! Trigger surface (keybinds wired in the GUI layer, §15):
10//! - `T` — cycle through the loaded message slots in INI order.
11//! - `Ctrl+Y` — clear every active message immediately.
12//!
13//! Also exposes a one-shot transient API (`show_transient`) used by
14//! the MPRIS auto-title path: when MPRIS reports a new track title,
15//! the engine spawns a temporary message with a synthesised default
16//! style (centred, fade-in / sustain / fade-out).
17
18use onedrop_parser::MessageDef;
19use std::collections::HashMap;
20
21/// Per-frame snapshot of one active message, fed to the renderer.
22/// Mirrors a tiny subset of [`MessageDef`] — the renderer only needs
23/// positioning + size + colour + the current fade alpha.
24#[derive(Debug, Clone)]
25pub struct MessageRenderInstance {
26    pub text: String,
27    pub font: u32,
28    pub size_px: f32,
29    /// Centre `[0, 1]` screen coords (top-left origin).
30    pub x: f32,
31    pub y: f32,
32    /// Final RGBA after fade. `a` is the fade phase × the def's
33    /// authored alpha (always `1.0` for now).
34    pub rgba: [f32; 4],
35    /// `true` on the *one* frame a `burn=1` message is finishing.
36    /// Currently treated identically to a regular emit — the text
37    /// pipeline renders into the display texture either way, so
38    /// "burning into the background" reduces to "draw once then
39    /// drop". Kept for future fence work.
40    pub burn: bool,
41}
42
43/// One live message instance.
44#[derive(Debug, Clone)]
45struct ActiveMessage {
46    /// Index into [`MessageManager::defs`]. Stays valid even after a
47    /// `load_defs` swap shrinks the vector — the tick reads via
48    /// `defs.get(idx)` which yields `None` if so and the instance
49    /// silently drops.
50    def_index: usize,
51    /// Engine time at which the instance was spawned (seconds).
52    /// Drives the fade timing.
53    spawned_at: f32,
54    /// Custom text override — used by the MPRIS auto-title path so we
55    /// can reuse one slot's styling for "now playing" without
56    /// committing to writing a `MILK_MSG.INI` first.
57    text_override: Option<String>,
58}
59
60/// Message system: definitions + active instances + cycle cursor.
61pub struct MessageManager {
62    defs: Vec<MessageDef>,
63    slot_to_index: HashMap<u32, usize>,
64    active: Vec<ActiveMessage>,
65    /// Cursor for the `T` keybind. Walks the defs vec mod `len`.
66    cycle_cursor: usize,
67    /// Fallback styling used by [`Self::show_transient`] when no
68    /// `MILK_MSG.INI` is loaded. Centred, white, 32 px, 0.5 s
69    /// fade-in / fade-out, 4 s sustain.
70    transient_default: MessageDef,
71}
72
73impl Default for MessageManager {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl MessageManager {
80    pub fn new() -> Self {
81        Self {
82            defs: Vec::new(),
83            slot_to_index: HashMap::new(),
84            active: Vec::new(),
85            cycle_cursor: 0,
86            transient_default: MessageDef {
87                slot: 0,
88                text: String::new(),
89                font: 0,
90                size: 40.0,
91                x: 0.5,
92                y: 0.92,
93                r: 255,
94                g: 255,
95                b: 255,
96                bold: false,
97                italic: false,
98                fade_in_s: 0.4,
99                fade_out_s: 0.6,
100                duration_s: 3.5,
101                burn: false,
102            },
103        }
104    }
105
106    /// Load a freshly-parsed set of message definitions, replacing
107    /// any previous load. Doesn't touch the active list.
108    pub fn load_defs(&mut self, defs: Vec<MessageDef>) {
109        self.slot_to_index.clear();
110        for (i, d) in defs.iter().enumerate() {
111            self.slot_to_index.insert(d.slot, i);
112        }
113        self.defs = defs;
114        self.cycle_cursor = 0;
115    }
116
117    pub fn def_count(&self) -> usize {
118        self.defs.len()
119    }
120
121    pub fn active_count(&self) -> usize {
122        self.active.len()
123    }
124
125    /// Spawn a message by INI slot at `time`. Multiple spawns of the
126    /// same slot stack — matches the sprite manager + MD2 behaviour.
127    pub fn spawn_slot(&mut self, slot: u32, time: f32) -> bool {
128        let Some(&idx) = self.slot_to_index.get(&slot) else {
129            return false;
130        };
131        self.active.push(ActiveMessage {
132            def_index: idx,
133            spawned_at: time,
134            text_override: None,
135        });
136        true
137    }
138
139    /// `T` keybind: cycle to the next loaded slot.
140    pub fn cycle_next(&mut self, time: f32) -> Option<u32> {
141        if self.defs.is_empty() {
142            return None;
143        }
144        let idx = self.cycle_cursor % self.defs.len();
145        let slot = self.defs[idx].slot;
146        self.cycle_cursor = (idx + 1) % self.defs.len();
147        self.active.push(ActiveMessage {
148            def_index: idx,
149            spawned_at: time,
150            text_override: None,
151        });
152        Some(slot)
153    }
154
155    /// Drop every active message (`Ctrl+Y` keybind).
156    pub fn clear(&mut self) {
157        self.active.clear();
158    }
159
160    /// Show a transient message — used by the MPRIS auto-title path.
161    /// Reuses the first loaded def's *style* (font, size, colour,
162    /// position, fade timing) so user-authored typography sticks; if
163    /// no def is loaded, the built-in `transient_default` is used.
164    /// The `text` argument overrides the def's `text=` field.
165    pub fn show_transient(&mut self, text: impl Into<String>, time: f32) {
166        let text = text.into();
167        if text.is_empty() {
168            return;
169        }
170        // Choose a styling source: first user-defined slot, or the
171        // built-in default. We store a `text_override` rather than
172        // mutating the def so re-triggering with another title
173        // doesn't poison the original.
174        let (def_index, _styling) = if let Some(def) = self.defs.first() {
175            (Some(0_usize), def)
176        } else {
177            (None, &self.transient_default)
178        };
179        match def_index {
180            Some(idx) => self.active.push(ActiveMessage {
181                def_index: idx,
182                spawned_at: time,
183                text_override: Some(text),
184            }),
185            None => {
186                // No loaded defs — synthesise a one-shot def in
187                // `defs[0]` so the tick can find it. The synthetic
188                // entry has slot 0 (reserved) so user spawns won't
189                // collide.
190                let mut def = self.transient_default.clone();
191                def.text = text;
192                self.defs.insert(0, def);
193                self.slot_to_index.clear();
194                for (i, d) in self.defs.iter().enumerate() {
195                    self.slot_to_index.insert(d.slot, i);
196                }
197                self.active.push(ActiveMessage {
198                    def_index: 0,
199                    spawned_at: time,
200                    text_override: None,
201                });
202            }
203        }
204    }
205
206    /// Advance every active message by one tick at engine time
207    /// `time`. Returns the list of [`MessageRenderInstance`]s for the
208    /// renderer, sorted in spawn order so later messages paint over
209    /// earlier ones.
210    pub fn tick(&mut self, time: f32) -> Vec<MessageRenderInstance> {
211        let mut out = Vec::with_capacity(self.active.len());
212        let mut keep = Vec::with_capacity(self.active.len());
213        for m in self.active.drain(..) {
214            let Some(def) = self.defs.get(m.def_index) else {
215                continue;
216            };
217            let age = (time - m.spawned_at).max(0.0);
218            let fade_in = def.fade_in_s.max(0.0);
219            let sustain = def.duration_s.max(0.0);
220            let fade_out = def.fade_out_s.max(0.0);
221            let total = fade_in + sustain + fade_out;
222            if age >= total && !def.burn {
223                continue;
224            }
225            let mut burn_emit = false;
226            let alpha = if age < fade_in {
227                if fade_in > 0.0 { age / fade_in } else { 1.0 }
228            } else if age < fade_in + sustain {
229                1.0
230            } else if age < total {
231                let t = (age - fade_in - sustain) / fade_out.max(1e-6);
232                (1.0 - t).clamp(0.0, 1.0)
233            } else {
234                // Past the total span: burn fires once at alpha 0
235                // so the burn frame still produces a glyph emit.
236                burn_emit = true;
237                0.0
238            };
239            let text = m.text_override.clone().unwrap_or_else(|| def.text.clone());
240            if text.is_empty() {
241                continue;
242            }
243            let rgba = [
244                def.r as f32 / 255.0,
245                def.g as f32 / 255.0,
246                def.b as f32 / 255.0,
247                alpha,
248            ];
249            out.push(MessageRenderInstance {
250                text,
251                font: def.font,
252                size_px: def.size,
253                x: def.x,
254                y: def.y,
255                rgba,
256                burn: burn_emit,
257            });
258            if !burn_emit {
259                keep.push(m);
260            }
261        }
262        self.active = keep;
263        out
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn def(slot: u32, text: &str, fade_in: f32, dur: f32, fade_out: f32) -> MessageDef {
272        MessageDef {
273            slot,
274            text: text.into(),
275            fade_in_s: fade_in,
276            duration_s: dur,
277            fade_out_s: fade_out,
278            ..Default::default()
279        }
280    }
281
282    #[test]
283    fn spawn_then_fade_in_full_alpha_at_end() {
284        let mut mgr = MessageManager::new();
285        mgr.load_defs(vec![def(1, "hi", 1.0, 2.0, 1.0)]);
286        mgr.spawn_slot(1, 0.0);
287        // Mid fade-in: alpha ~ 0.5.
288        let r = mgr.tick(0.5);
289        assert_eq!(r.len(), 1);
290        assert!((r[0].rgba[3] - 0.5).abs() < 1e-3);
291        // Full opacity during sustain.
292        let r = mgr.tick(2.0);
293        assert!((r[0].rgba[3] - 1.0).abs() < 1e-3);
294        // Mid fade-out.
295        let r = mgr.tick(3.5);
296        assert!((r[0].rgba[3] - 0.5).abs() < 1e-3);
297        // Past end: dropped (no burn).
298        let r = mgr.tick(5.0);
299        assert!(r.is_empty());
300        assert_eq!(mgr.active_count(), 0);
301    }
302
303    #[test]
304    fn cycle_walks_in_order() {
305        let mut mgr = MessageManager::new();
306        mgr.load_defs(vec![def(1, "a", 0.0, 1.0, 0.0), def(2, "b", 0.0, 1.0, 0.0)]);
307        assert_eq!(mgr.cycle_next(0.0), Some(1));
308        assert_eq!(mgr.cycle_next(0.0), Some(2));
309        assert_eq!(mgr.cycle_next(0.0), Some(1));
310    }
311
312    #[test]
313    fn clear_drops_everything() {
314        let mut mgr = MessageManager::new();
315        mgr.load_defs(vec![def(1, "a", 0.0, 1.0, 0.0)]);
316        mgr.spawn_slot(1, 0.0);
317        mgr.spawn_slot(1, 0.0);
318        assert_eq!(mgr.active_count(), 2);
319        mgr.clear();
320        assert_eq!(mgr.active_count(), 0);
321    }
322
323    #[test]
324    fn transient_works_without_any_defs() {
325        let mut mgr = MessageManager::new();
326        mgr.show_transient("Now playing: foo", 0.0);
327        assert_eq!(mgr.active_count(), 1);
328        let r = mgr.tick(0.5);
329        assert_eq!(r.len(), 1);
330        assert_eq!(r[0].text, "Now playing: foo");
331    }
332
333    #[test]
334    fn transient_overrides_first_def_text_only() {
335        let mut mgr = MessageManager::new();
336        mgr.load_defs(vec![def(1, "authored", 0.0, 10.0, 0.0)]);
337        mgr.show_transient("dynamic", 0.0);
338        let r = mgr.tick(0.0);
339        assert_eq!(r[0].text, "dynamic");
340        // Authored def still spawns its original text.
341        mgr.spawn_slot(1, 1.0);
342        let r = mgr.tick(1.5);
343        // Both alive: the transient (still in sustain) + the new spawn.
344        assert!(r.iter().any(|i| i.text == "authored"));
345        assert!(r.iter().any(|i| i.text == "dynamic"));
346    }
347}