1use crate::canvas::convert::IntoEgui as _;
15use crate::{
16 shell::glass::{self, Progress},
17 theme::{Role, Theme},
18};
19
20pub 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
37pub 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 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 .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#[derive(Clone)]
89struct Message {
90 said: String,
91 raised: f64,
92}
93
94#[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#[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 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 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 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
161const DWELL: core::time::Duration = core::time::Duration::from_millis(2400);
164
165const RISE: f32 = 20.0;
168
169const STANDOFF: f32 = 22.0;
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[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 #[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 #[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 #[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 #[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}