Skip to main content

blockworx/shell/
toast.rs

1//! The toast: what needs *attention*, said once at the bottom of the frame
2//! and never interactive.
3//!
4//! One surface, and one kind of news on it: a failure. What *worked* is not
5//! here — the routine confirmations belong to the
6//! [status line](super::status_line), because keeping the toast rare is what
7//! keeps it noticeable. It stays capable of carrying an action button when an
8//! attention event that has one arrives.
9//!
10//! It cannot be pressed and it carries no control, so it takes no room from
11//! the canvas: it is not a [`Berth`](super::glass::Berth), and the safe area
12//! never hears about it.
13
14use crate::canvas::convert::IntoEgui as _;
15use crate::{
16    shell::glass::{self, Progress},
17    theme::{Role, Theme},
18};
19
20/// Raise something that needs attention. Whatever was showing gives way — the
21/// mockup's toast is one element with one timer, and the newest news is the
22/// news.
23///
24/// Callable from a worker thread, which is where the file paths finish: an
25/// [`egui::Context`] is shared, and this asks it for a repaint on the way
26/// out.
27pub fn say(ctx: &egui::Context, said: impl Into<String>) {
28    let raised = ctx.input(|i| i.time);
29    let said = Message {
30        said: said.into(),
31        raised,
32    };
33    ctx.data_mut(|data| data.insert_temp(id(), said));
34    ctx.request_repaint();
35}
36
37/// Draw whatever is being said, wherever its motion has got to. Called every
38/// frame; a frame with nothing to say draws nothing and asks for nothing.
39pub fn toast(ctx: &egui::Context, theme: &Theme) {
40    let Some(message) = ctx.data(|data| data.get_temp::<Message>(id())) else {
41        return;
42    };
43    let elapsed =
44        core::time::Duration::from_secs_f64((ctx.input(|i| i.time) - message.raised).max(0.0));
45    let Some(motion) = Motion::at(elapsed) else {
46        ctx.data_mut(|data| data.remove::<Message>(id()));
47        return;
48    };
49    match motion.rest() {
50        // Holding still costs nothing until it is time to go, which is what
51        // lets an idle frame with a toast up still settle.
52        Some(left) => ctx.request_repaint_after(left),
53        None => ctx.request_repaint(),
54    }
55    let ink = theme.resolve(Role::ToastText).egui();
56    egui::Area::new(id())
57        .anchor(
58            egui::Align2::CENTER_BOTTOM,
59            egui::vec2(0.0, -STANDOFF + motion.offset()),
60        )
61        .order(egui::Order::Foreground)
62        .movable(false)
63        .fade_in(false)
64        .constrain(false)
65        // The mockup's `pointer-events:none`. The toast stands over the
66        // drawing for two seconds and must not eat a click meant for it.
67        .interactable(false)
68        .show(ctx, |ui| {
69            ui.set_opacity(motion.opacity());
70            glass::shell(
71                ui,
72                glass::Shape::Toast,
73                glass::Elevation::Floating,
74                glass::Tint::None,
75            )
76            .fill(theme.resolve(Role::Toast).egui())
77            .show(ui, |ui| {
78                glass::type_scale(ui, glass::Shape::Toast);
79                ui.label(egui::RichText::new(&message.said).color(ink));
80            });
81        });
82}
83
84/// What is being said, and when it started being said. Kept in egui's own
85/// memory rather than in the editor: a toast is chrome that dies with the
86/// frame it is on, and the paths that raise one — a dialog thread finishing
87/// a write — have a context and no editor.
88#[derive(Clone)]
89struct Message {
90    said: String,
91    raised: f64,
92}
93
94/// What the toast is saying — for a test that drives a real file path and
95/// asks what the user was told about it.
96#[cfg(test)]
97pub(crate) fn showing(ctx: &egui::Context) -> Option<String> {
98    ctx.data(|data| data.get_temp::<Message>(id()))
99        .map(|message| message.said)
100}
101
102fn id() -> egui::Id {
103    egui::Id::new("shell_toast")
104}
105
106/// Where a toast is in its life: rising, holding, or going. It reaches an
107/// end rather than a fixed point — nothing is left on screen — which is what
108/// makes the absent case free.
109#[derive(Clone, Copy, PartialEq, Debug)]
110enum Motion {
111    Rising(Progress),
112    Holding(core::time::Duration),
113    Leaving(Progress),
114}
115
116impl Motion {
117    fn at(elapsed: core::time::Duration) -> Option<Self> {
118        let through =
119            |span: core::time::Duration| Progress::new(span.div_duration_f32(glass::TOAST_MOTION));
120        if let Some(left) = DWELL.checked_sub(elapsed) {
121            return Some(if elapsed < glass::TOAST_MOTION {
122                Motion::Rising(through(elapsed))
123            } else {
124                Motion::Holding(left)
125            });
126        }
127        let going = elapsed.checked_sub(DWELL)?;
128        (going < glass::TOAST_MOTION).then(|| Motion::Leaving(through(going)))
129    }
130
131    /// How long until the next frame is owed, or `None` while the toast is
132    /// moving and owes one immediately.
133    fn rest(self) -> Option<core::time::Duration> {
134        match self {
135            Motion::Holding(left) => Some(left),
136            Motion::Rising(_) | Motion::Leaving(_) => None,
137        }
138    }
139
140    /// How far below its berth the toast is drawn — the mockup's
141    /// `translateY(20px)`, run through the shell's one curve, so it
142    /// overshoots coming up and again going down.
143    fn offset(self) -> f32 {
144        match self {
145            Motion::Rising(through) => RISE * (1.0 - glass::spring(through)),
146            Motion::Holding(_) => 0.0,
147            Motion::Leaving(through) => RISE * glass::spring(through),
148        }
149    }
150
151    /// The mockup fades the toast with the same transition it moves it by.
152    fn opacity(self) -> f32 {
153        match self {
154            Motion::Rising(through) => glass::spring(through).clamp(0.0, 1.0),
155            Motion::Holding(_) => 1.0,
156            Motion::Leaving(through) => 1.0 - glass::spring(through).clamp(0.0, 1.0),
157        }
158    }
159}
160
161/// How long the toast holds before it leaves — the mockup's own
162/// `setTimeout(…, 2400)`, measured from when it was raised.
163const DWELL: core::time::Duration = core::time::Duration::from_millis(2400);
164
165/// How far below its resting place the toast starts and ends — the mockup's
166/// `translateY(20px)`.
167const RISE: f32 = 20.0;
168
169/// Where it rests: the mockup's `.toast{bottom:22px}`, which is its own
170/// number rather than the frame's [`glass::MARGIN`] — the mockup stands the
171/// toast a little further off the edge than the chips beside it, and it is
172/// the piece that has to be read from wherever the eye happens to be.
173const STANDOFF: f32 = 22.0;
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    /// The three phases in order, and the end after them. A toast that never
180    /// ended would be chrome the user cannot dismiss.
181    #[test]
182    fn a_toast_rises_holds_goes_and_is_over() {
183        let at = |millis| Motion::at(core::time::Duration::from_millis(millis));
184        assert!(matches!(at(0), Some(Motion::Rising(_))));
185        assert!(matches!(at(200), Some(Motion::Rising(_))));
186        assert!(matches!(at(1_000), Some(Motion::Holding(_))));
187        assert!(matches!(at(2_500), Some(Motion::Leaving(_))));
188        assert_eq!(at(2_651), None, "the toast outlived its own exit");
189    }
190
191    /// It comes up from below and goes back down the same way, and it is
192    /// only ever whole in the middle.
193    #[test]
194    fn it_travels_the_mockups_twenty_pixels_and_fades_with_the_move() {
195        let at = |millis| {
196            Motion::at(core::time::Duration::from_millis(millis)).expect("the toast is up")
197        };
198        assert_eq!(
199            at(0).offset(),
200            RISE,
201            "the toast does not start below where it rests",
202        );
203        assert_eq!(at(0).opacity(), 0.0);
204        assert_eq!(at(1_000).offset(), 0.0);
205        assert_eq!(at(1_000).opacity(), 1.0);
206        assert!(
207            at(2_600).offset() > 0.0,
208            "the toast leaves upward, or not at all",
209        );
210        assert!(at(2_600).opacity() < 1.0);
211        for millis in [0, 100, 200, 1_000, 2_450, 2_600] {
212            let opacity = at(millis).opacity();
213            assert!(
214                (0.0..=1.0).contains(&opacity),
215                "the spring's overshoot reached the fade at {millis}ms: {opacity}",
216            );
217        }
218    }
219
220    /// Only a moving toast owes an immediate frame; a holding one owes the
221    /// frame it will leave on and nothing before it. This is what keeps the
222    /// settle probe honest with a toast on screen.
223    #[test]
224    fn a_holding_toast_asks_for_one_frame_and_not_a_stream_of_them() {
225        let at = |millis| {
226            Motion::at(core::time::Duration::from_millis(millis)).expect("the toast is up")
227        };
228        assert_eq!(at(0).rest(), None, "a rising toast owes a frame now");
229        assert_eq!(
230            at(1_000).rest(),
231            Some(core::time::Duration::from_millis(1_400)),
232            "a holding toast asked for the wrong next frame",
233        );
234        assert_eq!(at(2_500).rest(), None);
235    }
236
237    /// Through real frames: the toast draws its words, then stops drawing
238    /// them, and the frames after it is gone ask for nothing — a toast that
239    /// pinned the repaint delay at zero would redraw the editor at full rate
240    /// for as long as the app was open.
241    #[test]
242    fn a_said_toast_settles_after_it_has_been_said() {
243        let mut said = false;
244        let theme = Theme::default();
245        let settle = crate::canvas::settle::probe(200, |ui| {
246            if !said {
247                say(ui.ctx(), "Could not export engine.pdf");
248                said = true;
249            }
250            toast(ui.ctx(), &theme);
251        });
252        assert!(
253            settle.shape_counts[..40].iter().any(|count| *count > 0),
254            "the toast never drew: {:?}",
255            &settle.shape_counts[..40],
256        );
257        assert_eq!(
258            settle.shape_counts.last(),
259            Some(&0),
260            "the toast was still on screen three seconds later",
261        );
262        crate::canvas::settle::assert_settles(&settle, 190);
263    }
264
265    /// The newest news is the news: saying a second thing replaces the
266    /// first rather than queueing behind it, exactly as the mockup's one
267    /// element and one timer do.
268    #[test]
269    fn the_second_thing_said_is_what_shows() {
270        let ctx = egui::Context::default();
271        ctx.run_ui(egui::RawInput::default(), |ui| {
272            say(ui.ctx(), "Could not export engine.pdf");
273            say(ui.ctx(), "Could not save as engine");
274        })
275        .drop_without_applying_deltas();
276        assert_eq!(showing(&ctx).as_deref(), Some("Could not save as engine"),);
277    }
278}