denise-ui 0.10.1

Scene graph, widgets and compositor for Denise.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
//! Transient notifications: appear, hold, fade, gone.
//!
//! The widget [#19] was written for. A toast is never focused, so under the old
//! rule — only the focused widget animates — it could not exist at all.
//!
//! Like the tooltip in [`crate::tooltip`], it is **not a node**, and for the
//! same kind of reasons rather than by analogy: removing itself is the whole
//! point and only the tree can remove things, two toasts must not land on top
//! of each other and no widget can see its siblings, and not being a node is
//! what makes it invisible to Tab and to hit testing without anybody
//! remembering to make it so.
//!
//! # It is the transient half of `Alert`
//!
//! [`Alert`](crate::widgets::Alert) stays exactly as it is: an **inline**
//! banner, in the layout, where the thing it is about would be. A toast is the
//! same message when there is nowhere in the layout to put it — a save that
//! succeeded, a reading that went out of range — and it goes away by itself.
//!
//! # What it costs, which is almost nothing
//!
//! A toast is mostly *idle*. It fades in, holds for a few seconds, and fades
//! out; only the fades need frames. During the hold the tree asks to be woken
//! **once**, at the moment the fade-out starts — not sixty times a second for
//! four seconds.
//!
//! That is the opposite of [`Spinner`](crate::widgets::Spinner), which looks
//! like the same kind of feature and costs a wake per frame for as long as it
//! is up.
//!
//! [#19]: https://github.com/bisand/denise/issues/19

use alloc::string::String;
use alloc::vec::Vec;

use denise::{Color, Point, Radius, Rect, Role, Size, Theme};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};

/// How long a toast takes to fade in.
const FADE_IN_MS: u64 = 120;

/// How long it takes to fade out.
///
/// Longer than the fade in: something arriving should be quick, something
/// leaving should not look like it was snatched away mid-read.
const FADE_OUT_MS: u64 = 280;

/// How long it holds at full opacity, unless the caller says otherwise.
///
/// Long enough to read a short sentence twice, short enough that a panel does
/// not accumulate a wall of them.
pub(crate) const HOLD_MS: u64 = 4_000;

/// Space between a toast and the surface edge, and between two toasts.
const MARGIN: i32 = 12;

/// Space between the text and the toast's edge.
const PADDING: i32 = 10;

/// The widest a toast gets before its text wraps.
const MAX_WIDTH: i32 = 420;

/// How many show at once. Older ones are dropped rather than queued: a
/// notification nobody has seen yet is worth more than one from ten seconds
/// ago, and a panel that showed a backlog would be unreadable exactly when
/// something was going wrong.
const MAX_VISIBLE: usize = 3;

/// One notification, and when it was born.
#[derive(Clone, Debug)]
struct Note {
    text: String,
    role: Role,
    born_ms: u64,
    hold_ms: u64,
}

impl Note {
    /// How long this toast lives in total.
    #[inline]
    const fn life_ms(&self) -> u64 {
        FADE_IN_MS + self.hold_ms + FADE_OUT_MS
    }

    /// Opacity at `now_ms`, `0..=255`, and `None` once it has expired.
    fn alpha(&self, now_ms: u64) -> Option<u8> {
        let age = now_ms.saturating_sub(self.born_ms);
        if age >= self.life_ms() {
            return None;
        }
        if age < FADE_IN_MS {
            return Some((age * 255 / FADE_IN_MS.max(1)) as u8);
        }
        let fading_at = FADE_IN_MS + self.hold_ms;
        if age < fading_at {
            return Some(255);
        }
        let out = age - fading_at;
        Some((255 - (out * 255 / FADE_OUT_MS.max(1)).min(255)) as u8)
    }

    /// When this toast next needs a frame.
    ///
    /// **The point of the whole design.** During the fades it wants the next
    /// frame; during the hold it wants exactly one wake, at the instant the
    /// fade-out starts. A toast holding for four seconds therefore costs one
    /// wake, not two hundred and forty.
    fn next_wake(&self, now_ms: u64, frame_ms: u64) -> u64 {
        let age = now_ms.saturating_sub(self.born_ms);
        let fading_at = FADE_IN_MS + self.hold_ms;
        if age < FADE_IN_MS {
            now_ms + frame_ms
        } else if age < fading_at {
            self.born_ms + fading_at
        } else {
            now_ms + frame_ms
        }
    }
}

/// The notification stack, owned by [`Ui`](crate::Ui).
#[derive(Clone, Debug)]
pub(crate) struct Toasts {
    notes: Vec<Note>,
    /// The area the last paint actually covered.
    ///
    /// Damage needs *where the pixels are*, and by the time a toast has expired
    /// the layout no longer includes it — so asking the current stack where to
    /// repaint would miss exactly the one that just went. Remembering what was
    /// painted is the only answer that survives a toast leaving, and it is the
    /// same lesson the tooltip's damage taught: measure before the state that
    /// knows the answer is gone.
    last_painted: Option<Rect>,
    style: TextStyle,
    /// How often a *fading* toast asks to be redrawn. A fade is short, so this
    /// is the one place the toolkit spends frames freely.
    frame_ms: u64,
}

impl Toasts {
    pub(crate) fn new() -> Self {
        Self {
            notes: Vec::new(),
            last_painted: None,
            style: TextStyle::built_in(16),
            frame_ms: 33,
        }
    }

    /// Adds a notification, dropping the oldest if the stack is full.
    pub(crate) fn push(&mut self, text: String, role: Role, hold_ms: u64, now_ms: u64) {
        if self.notes.len() >= MAX_VISIBLE {
            self.notes.remove(0);
        }
        self.notes.push(Note {
            text,
            role,
            born_ms: now_ms,
            hold_ms,
        });
    }

    /// How many are on screen.
    #[inline]
    pub(crate) fn len(&self) -> usize {
        self.notes.len()
    }

    /// Removes everything, whether or not it had been read.
    pub(crate) fn clear(&mut self) {
        self.notes.clear();
    }

    /// Drops expired toasts. Returns `true` if any went.
    pub(crate) fn retire(&mut self, now_ms: u64) -> bool {
        let before = self.notes.len();
        self.notes.retain(|note| note.alpha(now_ms).is_some());
        self.notes.len() != before
    }

    /// Whether anything on screen will look different from one frame to the
    /// next: a toast mid-fade, or one whose time is up.
    ///
    /// **The cost claim depends on this being false during the hold.** A stack
    /// that damaged itself every tick would repaint the bottom of the screen
    /// sixty times a second for four seconds to show a picture that never
    /// changed — which is the thing this whole design exists to avoid.
    pub(crate) fn is_changing(&self, now_ms: u64) -> bool {
        self.notes.iter().any(|note| {
            let age = now_ms.saturating_sub(note.born_ms);
            age < FADE_IN_MS || age >= FADE_IN_MS + note.hold_ms
        })
    }

    /// The soonest any toast needs a frame.
    pub(crate) fn next_wake(&self, now_ms: u64) -> Option<u64> {
        self.notes
            .iter()
            .map(|note| note.next_wake(now_ms, self.frame_ms))
            .min()
    }

    /// Dismisses the toast containing `point`, if any.
    ///
    /// **A press on a toast must not reach what is underneath.** A toast is not
    /// a node, so nothing else will stop the press: somebody dismissing a
    /// notification would press the button it was covering, which is the
    /// dropdown bug in a new hat. Returns `true` when the press was consumed.
    pub(crate) fn dismiss_at(
        &mut self,
        point: Point,
        surface: Size,
        engine: &mut TextEngine,
        now_ms: u64,
    ) -> bool {
        let hit = self
            .placed(surface, engine, now_ms)
            .into_iter()
            .find(|(_, rect, _)| rect.contains(point))
            .map(|(index, _, _)| index);
        match hit {
            Some(index) => {
                self.notes.remove(index);
                true
            }
            None => false,
        }
    }

    /// Every visible toast: **its index in `notes`**, its rectangle and its
    /// opacity, newest nearest the corner.
    ///
    /// Stacked upwards from the bottom of the surface: a panel's content starts
    /// at the top, and on a touchscreen the bottom is where a thumb already is.
    ///
    /// The index is carried rather than implied. This walks the stack backwards
    /// *and* skips anything already expired, so a position in this list is not a
    /// position in `notes` — which is exactly the mistake the first version
    /// made twice: dismissing a toast removed a different one, and painting
    /// zipped rectangles against the wrong messages. Neither shows up with one
    /// toast on screen.
    fn placed(
        &self,
        surface: Size,
        engine: &mut TextEngine,
        now_ms: u64,
    ) -> Vec<(usize, Rect, u8)> {
        let mut out = Vec::new();
        let mut bottom = surface.height as i32 - MARGIN;
        for (index, note) in self.notes.iter().enumerate().rev() {
            let Some(alpha) = note.alpha(now_ms) else {
                continue;
            };
            let size = measure(note, self.style, surface, engine);
            let rect = Rect::new(
                (surface.width as i32 - size.width as i32) / 2,
                bottom - size.height as i32,
                size.width as i32,
                size.height as i32,
            );
            bottom = rect.y - MARGIN;
            out.push((index, rect, alpha));
        }
        out
    }

    /// What to repaint: what the last paint covered, and what the next one
    /// will. Either alone is wrong — the first misses a toast arriving, the
    /// second misses one that has just gone.
    pub(crate) fn bounds(
        &self,
        surface: Size,
        engine: &mut TextEngine,
        now_ms: u64,
    ) -> Option<Rect> {
        let next = self
            .placed(surface, engine, now_ms)
            .into_iter()
            .map(|(_, rect, _)| rect)
            .reduce(|a, b| a.union(&b));
        match (self.last_painted, next) {
            (Some(a), Some(b)) => Some(a.union(&b)),
            (a, b) => a.or(b),
        }
    }

    /// Draws the stack.
    pub(crate) fn paint(
        &mut self,
        theme: &Theme,
        surface: Size,
        engine: &mut TextEngine,
        now_ms: u64,
        canvas: &mut Canvas<'_>,
    ) {
        let placed = self.placed(surface, engine, now_ms);
        self.last_painted = placed
            .iter()
            .map(|&(_, rect, _)| rect)
            .reduce(|a, b| a.union(&b));
        for &(index, rect, alpha) in &placed {
            let note = &self.notes[index];
            // Both colours from one pairing, and both faded together — the
            // whole reason a toast is a widget-shaped thing rather than a
            // `fill_rect` and a `draw_text` in an application.
            let (fill, content) = theme.pair(note.role);
            canvas.fill_rounded_rect(rect, theme.radius(Radius::Box), fade(fill, alpha));

            let available = rect.width - PADDING * 2;
            let line_height = engine.line_height(self.style);
            let lines: Vec<&str> = engine.wrap(self.style, &note.text, available.max(1));
            for (index, line) in lines.iter().enumerate() {
                let y = rect.y + PADDING + index as i32 * line_height;
                if y >= rect.bottom() {
                    break;
                }
                engine.draw(
                    canvas,
                    self.style,
                    Point::new(rect.x + PADDING, y),
                    line,
                    fade(content, alpha),
                );
            }
        }
    }
}

/// A colour at a fraction of its own alpha.
fn fade(color: Color, alpha: u8) -> Color {
    Color::rgba(
        color.r,
        color.g,
        color.b,
        ((color.a as u32 * alpha as u32) / 255) as u8,
    )
}

/// How big a toast is, once its text has wrapped.
fn measure(note: &Note, style: TextStyle, surface: Size, engine: &mut TextEngine) -> Size {
    let limit = MAX_WIDTH.min(surface.width as i32 - MARGIN * 2).max(1);
    let available = (limit - PADDING * 2).max(1);
    let height = engine.wrapped_height(style, &note.text, available);
    let lines: Vec<&str> = engine.wrap(style, &note.text, available);
    let widest = lines
        .iter()
        .map(|line| engine.measure_line(style, line))
        .max()
        .unwrap_or(0);
    Size::new(
        (widest + PADDING * 2).clamp(1, limit) as u32,
        (height + PADDING * 2).max(1) as u32,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    const SURFACE: Size = Size::new(400, 240);

    fn note(now_ms: u64) -> Note {
        Note {
            text: String::from("Lagret"),
            role: Role::Success,
            born_ms: now_ms,
            hold_ms: HOLD_MS,
        }
    }

    /// The whole life, without anybody touching it: in, hold, out, gone.
    #[test]
    fn a_toast_fades_in_holds_and_fades_out_by_itself() {
        let note = note(1_000);
        assert_eq!(note.alpha(1_000), Some(0), "born invisible");
        assert!(note.alpha(1_000 + FADE_IN_MS / 2).expect("fading in") > 0);
        assert_eq!(note.alpha(1_000 + FADE_IN_MS), Some(255), "arrived");
        assert_eq!(
            note.alpha(1_000 + FADE_IN_MS + HOLD_MS / 2),
            Some(255),
            "holding"
        );

        let fading = 1_000 + FADE_IN_MS + HOLD_MS + FADE_OUT_MS / 2;
        let half = note.alpha(fading).expect("fading out");
        assert!((80..180).contains(&half), "half way out is {half}");

        assert_eq!(note.alpha(1_000 + note.life_ms()), None, "gone");
        assert_eq!(note.alpha(u64::MAX), None, "and stays gone");
    }

    /// **The cost claim.** A holding toast asks for one wake, at the instant it
    /// starts fading — not a frame cadence for four seconds.
    #[test]
    fn a_holding_toast_asks_for_exactly_one_wake() {
        let note = note(0);
        let fading_at = FADE_IN_MS + HOLD_MS;

        // Just after it arrives, the next wake is the whole hold away.
        let wake = note.next_wake(FADE_IN_MS, 33);
        assert_eq!(wake, fading_at, "a holding toast wakes once, at the fade");

        // Still one wake, most of the way through the hold.
        assert_eq!(note.next_wake(fading_at - 1, 33), fading_at);

        // During the fades it wants frames.
        assert_eq!(note.next_wake(0, 33), 33, "fading in");
        assert_eq!(note.next_wake(fading_at + 10, 33), fading_at + 43, "out");
    }

    /// A tree with no toasts asks for nothing at all.
    #[test]
    fn an_empty_stack_wakes_for_nothing() {
        let toasts = Toasts::new();
        assert_eq!(toasts.next_wake(0), None);
        assert_eq!(toasts.len(), 0);
    }

    /// Expired toasts are dropped, and `retire` says whether anything went so
    /// the tree knows to repaint.
    #[test]
    fn expired_toasts_are_retired() {
        let mut toasts = Toasts::new();
        toasts.push(String::from("Lagret"), Role::Success, HOLD_MS, 0);
        assert!(!toasts.retire(100), "still alive");
        assert_eq!(toasts.len(), 1);

        assert!(toasts.retire(FADE_IN_MS + HOLD_MS + FADE_OUT_MS));
        assert_eq!(toasts.len(), 0);
        assert!(!toasts.retire(u64::MAX), "nothing left to retire");
    }

    /// Two toasts stack without overlapping, newest nearest the bottom edge.
    #[test]
    fn toasts_stack_without_overlapping() {
        let mut engine = TextEngine::new();
        let mut toasts = Toasts::new();
        toasts.push(String::from("Først"), Role::Info, HOLD_MS, 0);
        toasts.push(String::from("Så dette"), Role::Success, HOLD_MS, 0);

        let placed = toasts.placed(SURFACE, &mut engine, FADE_IN_MS);
        assert_eq!(placed.len(), 2);
        let (newest_index, newest, _) = placed[0];
        let (oldest_index, oldest, _) = placed[1];
        assert_eq!(newest_index, 1, "the newest is the last one pushed");
        assert_eq!(oldest_index, 0);
        assert!(
            newest.y > oldest.y,
            "the newest should be nearest the bottom: {newest:?} {oldest:?}"
        );
        assert!(
            oldest.bottom() <= newest.y,
            "they overlap: {oldest:?} {newest:?}"
        );
        assert!(
            newest.bottom() <= SURFACE.height as i32 - MARGIN,
            "the stack ran off the bottom"
        );
        for (_, rect, _) in &placed {
            assert!(rect.x >= 0 && rect.right() <= SURFACE.width as i32);
        }
    }

    /// The stack is capped: a panel that showed a backlog would be unreadable
    /// exactly when something was going wrong.
    #[test]
    fn the_oldest_is_dropped_when_the_stack_is_full() {
        let mut toasts = Toasts::new();
        for i in 0..MAX_VISIBLE + 2 {
            toasts.push(alloc::format!("Melding {i}"), Role::Info, HOLD_MS, 0);
        }
        assert_eq!(toasts.len(), MAX_VISIBLE);
        assert_eq!(
            toasts.notes[0].text, "Melding 2",
            "the oldest two should have gone"
        );
    }

    /// A press inside a toast dismisses it and reports that it was consumed —
    /// a press outside is not this stack's business.
    #[test]
    fn a_press_inside_a_toast_dismisses_it_and_is_consumed() {
        let mut engine = TextEngine::new();
        let mut toasts = Toasts::new();
        toasts.push(String::from("Lagret"), Role::Success, HOLD_MS, 0);
        let (_, rect, _) = toasts.placed(SURFACE, &mut engine, FADE_IN_MS)[0];

        let outside = Point::new(rect.x - 5, rect.y - 5);
        assert!(!toasts.dismiss_at(outside, SURFACE, &mut engine, FADE_IN_MS));
        assert_eq!(toasts.len(), 1, "and it is still there");

        let inside = Point::new(rect.x + 2, rect.y + 2);
        assert!(toasts.dismiss_at(inside, SURFACE, &mut engine, FADE_IN_MS));
        assert_eq!(toasts.len(), 0);
    }

    /// Dismissing the right one of several. The first version removed by the
    /// position in the *placed* list, which walks backwards — so tapping the
    /// newest removed the oldest. Invisible with one toast on screen.
    #[test]
    fn dismissing_removes_the_toast_that_was_pressed() {
        let mut engine = TextEngine::new();
        let mut toasts = Toasts::new();
        toasts.push(String::from("Først"), Role::Info, HOLD_MS, 0);
        toasts.push(String::from("Andre"), Role::Success, HOLD_MS, 0);
        toasts.push(String::from("Tredje"), Role::Error, HOLD_MS, 0);

        // The newest is nearest the bottom edge, and is `notes[2]`.
        let placed = toasts.placed(SURFACE, &mut engine, FADE_IN_MS);
        let (_, newest, _) = placed[0];
        assert!(toasts.dismiss_at(
            Point::new(newest.x + 2, newest.y + 2),
            SURFACE,
            &mut engine,
            FADE_IN_MS
        ));
        assert_eq!(toasts.len(), 2);
        assert_eq!(
            [toasts.notes[0].text.as_str(), toasts.notes[1].text.as_str()],
            ["Først", "Andre"],
            "the wrong toast was dismissed"
        );

        // And the oldest, now at the top of the stack.
        let placed = toasts.placed(SURFACE, &mut engine, FADE_IN_MS);
        let (_, oldest, _) = placed[1];
        assert!(toasts.dismiss_at(
            Point::new(oldest.x + 2, oldest.y + 2),
            SURFACE,
            &mut engine,
            FADE_IN_MS
        ));
        assert_eq!(toasts.notes[0].text, "Andre");
    }

    /// An expired toast still in the list must not shift the ones after it out
    /// of alignment with their messages — `placed` skips it, so it carries the
    /// index rather than implying one.
    #[test]
    fn an_expired_toast_does_not_misalign_the_rest() {
        let mut engine = TextEngine::new();
        let mut toasts = Toasts::new();
        toasts.push(String::from("Gammel"), Role::Info, 0, 0);
        toasts.push(String::from("Ny"), Role::Success, HOLD_MS, 0);

        // The first has expired; the second has not.
        let late = FADE_IN_MS + FADE_OUT_MS + 1;
        let placed = toasts.placed(SURFACE, &mut engine, late);
        assert_eq!(placed.len(), 1, "only the live one is placed");
        assert_eq!(placed[0].0, 1, "and it knows which message it is");
    }

    /// Long text wraps rather than running off the surface.
    #[test]
    fn a_long_message_wraps_instead_of_overflowing() {
        let mut engine = TextEngine::new();
        let short = measure(&note(0), TextStyle::built_in(16), SURFACE, &mut engine);
        let long = Note {
            text: String::from("Kunne ikke lagre fordi disken er full og det er ingen plass igjen"),
            ..note(0)
        };
        let wrapped = measure(&long, TextStyle::built_in(16), SURFACE, &mut engine);
        assert!(wrapped.height > short.height, "it did not wrap");
        assert!(
            wrapped.width as i32 <= SURFACE.width as i32 - MARGIN * 2,
            "it is wider than the surface allows: {wrapped:?}"
        );
    }

    /// Fading scales alpha and leaves the colour alone, so a toast does not
    /// change hue as it goes.
    #[test]
    fn fading_touches_only_the_alpha() {
        let colour = Color::rgba(200, 100, 50, 255);
        assert_eq!(fade(colour, 255), colour);
        let half = fade(colour, 128);
        assert_eq!((half.r, half.g, half.b), (200, 100, 50));
        assert!(half.a < colour.a && half.a > 0);
        assert_eq!(fade(colour, 0).a, 0);
    }
}