Skip to main content

ui/
notify.rs

1// notify -- transient feedback: toasts, spinner, indeterminate progress
2//
3//   toast(kind, title, msg, ms)         queue a notification card (IB_INFO .. IB_ERROR; ms 0
4//                                       = 5 s, u32::MAX = until closed); returns its id
5//   toast_host()                        draw the queued toasts (bottom right, newest at the
6//                                       bottom, above dialogs); call it once per frame after
7//                                       the content.  Hovering keeps a toast; its close button
8//                                       removes it; expiry wakes the loop (timer UIT_TOAST).
9//                                       Records: the card (K_BOX, the title), its message
10//                                       (K_TEXT) and the close button (K_BUTTON "Schließen").
11//   toast_dismiss(id) / toast_clear()
12//   toasts() -> [(id, kind, title, msg)]   the queued toasts (a notification history)
13//   spinner(id, size_dip)               a turning ring while something loads
14//   progress_indeterminate(id)          a bar with a running segment (natural 200 x 4 DIP)
15// The animations keep the window repainting while they are shown.
16
17use alloc::string::String;
18use alloc::vec::Vec;
19
20use crate::consts::*;
21use crate::{Sp, Ui};
22
23#[derive(Clone)]
24struct Toast {
25    id: u32,
26    kind: u32,
27    title: String,
28    msg: String,
29    until: u32,
30    forever: bool,
31}
32
33/// shared state: the toast queue
34#[derive(Default)]
35pub(crate) struct S {
36    toasts: Vec<Toast>,
37    next: u32,
38}
39
40// toasts shown at once: a fixed display policy, not a queue (a sixth closes the oldest early,
41// as the notification stacks of Windows and GNOME do)
42const TOAST_MAX: usize = 5;
43
44impl Ui {
45    /// ui_toast(kind, title, msg, ms) -> toast id
46    pub fn toast(&mut self, kind: u32, title: &str, msg: &str, ms: u32) -> u32 {
47        let n = &mut self.m.notify;
48        n.next = n.next.wrapping_add(1).max(1);
49        let id = n.next;
50        let forever = ms == u32::MAX;
51        let dur = if ms == 0 { 5000 } else { ms };
52        let until = self.now_ms.wrapping_add(if forever { 0 } else { dur });
53        let n = &mut self.m.notify;
54        n.toasts.push(Toast { id, kind: kind & 3, title: String::from(title), msg: String::from(msg), until, forever });
55        while n.toasts.len() > TOAST_MAX {
56            n.toasts.remove(0);
57        }
58        if !forever {
59            self.timer(UIT_TOAST, dur);
60        }
61        self.repass();
62        id
63    }
64
65    /// ui_toast_dismiss(id)
66    pub fn toast_dismiss(&mut self, id: u32) {
67        self.m.notify.toasts.retain(|t| t.id != id);
68        self.repass();
69    }
70
71    /// ui_toast_clear
72    pub fn toast_clear(&mut self) {
73        self.m.notify.toasts.clear();
74        self.repass();
75    }
76
77    /// the queued toasts: (id, kind, title, message)
78    pub fn toasts(&self) -> Vec<(u32, u32, String, String)> {
79        self.m.notify.toasts.iter().map(|t| (t.id, t.kind, t.title.clone(), t.msg.clone())).collect()
80    }
81
82    /// ui_toast_host: draw the toasts (once per frame, after the content)
83    #[cfg_attr(jfgui_dev, track_caller)]
84    pub fn toast_host(&mut self) {
85        let _src = self.src_scope();
86        let now = self.now_ms;
87        // expire
88        let before = self.m.notify.toasts.len();
89        self.m.notify.toasts.retain(|t| t.forever || (t.until.wrapping_sub(now) as i32) > 0);
90        if self.m.notify.toasts.len() != before {
91            self.repass();
92        }
93        if self.m.notify.toasts.is_empty() {
94            return;
95        }
96        let (ww, wh) = (self.wp.rt.fb.w, self.wp.rt.fb.h);
97        let w = self.dp(360).min(ww - self.dp(32));
98        let h = self.dp(64);
99        let gap = self.dp(10);
100        let m = self.dp(16);
101        let prev_layer = self.layer_set(L_TIP);
102        let clip = self.wp.ctx.uiclip;
103        let lr = self.wp.ctx.last_rect;
104        self.wp.ctx.uiclip = [0, 0, ww, wh];
105        let toasts = self.m.notify.toasts.clone();
106        let mut y = wh - m - h - self.dp(28); // above a status bar
107        let mut next_due = u32::MAX;
108        let sh = self.style.shell();
109        for t in toasts.iter().rev() {
110            let r = [ww - m - w, y, w, h];
111            let tid = 0xFFFF_7500u32.wrapping_add(t.id & 0xFF);
112            let ev = self.hit_rect(tid, r[0], r[1], r[2], r[3], HB_BLOCK);
113            let _ = ev;
114            let hovered = self.wp.ctx.hot == tid || (self.mouse().0 >= r[0] && self.mouse().0 < r[0] + r[2] && self.mouse().1 >= r[1] && self.mouse().1 < r[1] + r[3]);
115            let mut sp = Sp { flags: t.kind, label: Some(&t.title), label2: Some(&t.msg), ..Sp::default() };
116            sp.place(r);
117            sh.toast(self, &mut sp);
118            let (cx, cs) = (sp.s[0], sp.s[1]);
119            let cid = 0xFFFF_7600u32.wrapping_add(t.id & 0xFF);
120            let cr = [cx, r[1] + self.dp(10), cs, cs];
121            let cev = self.hit_rect(cid, cr[0], cr[1], cr[2], cr[3], HB_FOCUS);
122            if cev & EV_HOVER != 0 {
123                let c = self.color(T_SUBTLE_HOVER);
124                if c & 0xFF00_0000 != 0 {
125                    self.cmd_rrect(cr[0], cr[1], cr[2], cr[3], self.dp(4), 0, c, 0, 0);
126                }
127            }
128            let c = self.color(T_TEXT2);
129            let px = self.dp(12);
130            crate::stock::icon_in(self, crate::keys::IC_CANCEL, cr, px, c);
131            self.sem(K_BUTTON, cid, Some(crate::i18n::tr("Schließen")), 0);
132            self.wp.ctx.last_rect = r;
133            self.wp.ctx.last_state = 0;
134            self.sem(K_BOX, tid, Some(&t.title), t.kind as i32);
135            // the message: a text record of its own below the title (screen readers, scripts)
136            if !t.msg.is_empty() {
137                let mx = r[0] + self.dp(44);
138                self.wp.ctx.last_rect = [mx, r[1] + self.dp(32), (cx - mx - self.dp(4)).max(0), self.dp(20)];
139                self.sem(K_TEXT, 0, Some(&t.msg), 0);
140            }
141            if cev & EV_ACT != 0 {
142                let id = t.id;
143                self.m.notify.toasts.retain(|x| x.id != id);
144                self.repass();
145            } else if hovered && !t.forever {
146                // hovering keeps the toast for another 2 s
147                let until = now.wrapping_add(2000);
148                if let Some(x) = self.m.notify.toasts.iter_mut().find(|x| x.id == t.id) {
149                    if (x.until.wrapping_sub(until) as i32) < 0 {
150                        x.until = until;
151                    }
152                }
153            }
154            if !t.forever {
155                let left = t.until.wrapping_sub(now);
156                if (left as i32) > 0 && left < next_due {
157                    next_due = left;
158                }
159            }
160            y -= h + gap;
161            if y < m {
162                break;
163            }
164        }
165        if next_due != u32::MAX {
166            self.timer(UIT_TOAST, next_due.max(16));
167        }
168        self.wp.ctx.uiclip = clip;
169        self.wp.ctx.last_rect = lr;
170        self.layer_set(prev_layer);
171    }
172
173    // a running animation asks for frames only while some of it shows: a spinner scrolled out
174    // of view kept the loop at 60 wake-ups a second (r: px; an empty rect, not laid out yet,
175    // counts as shown)
176    fn anim_shown(&self, r: [i32; 4]) -> bool {
177        let k = self.wp.ctx.uiclip;
178        r[2] <= 0 || r[3] <= 0 || (r[0] < k[2] && r[1] < k[3] && r[0] + r[2] > k[0] && r[1] + r[3] > k[1])
179    }
180
181    /// ui_spinner(id, size_dip): a turning ring
182    #[cfg_attr(jfgui_dev, track_caller)]
183    pub fn spinner(&mut self, id: u32, size: i32) {
184        let _src = self.src_scope();
185        let s = self.dp(if size > 0 { size } else { 20 });
186        let r = self.place(s, s);
187        let phase = if self.anim_off { 0 } else { ((self.now_ms as u64 * 1024 / 900) & 1023) as i32 };
188        if !self.anim_off && self.anim_shown(r) {
189            self.animating = true;
190        }
191        let sh = self.style.shell();
192        let mut sp = Sp { value: phase, ..Sp::default() };
193        sp.place([r[0] + ((r[2] - s) >> 1), r[1] + ((r[3] - s) >> 1), s, s]);
194        sh.spinner(self, &sp);
195        self.wp.ctx.last_state = 0;
196        self.sem(K_PROGRESS, id, None, -1);
197    }
198
199    /// ui_progress_indeterminate(id): a bar with a running segment
200    #[cfg_attr(jfgui_dev, track_caller)]
201    pub fn progress_indeterminate(&mut self, id: u32) {
202        let _src = self.src_scope();
203        let r = self.place(self.dp(200), self.dp(4));
204        let phase = if self.anim_off { 300 } else { ((self.now_ms as u64 * 1024 / 1500) & 1023) as i32 };
205        if !self.anim_off && self.anim_shown(r) {
206            self.animating = true;
207        }
208        let sh = self.style.shell();
209        let mut sp = Sp { value: phase, ..Sp::default() };
210        sp.place(r);
211        sh.progress_indeterminate(self, &sp);
212        self.wp.ctx.last_state = 0;
213        self.sem(K_PROGRESS, id, None, -1);
214    }
215}