Another project
1use core::time::Duration;
2
3use bone_types::IconId;
4
5use crate::a11y::{AccessNode, Role};
6use crate::frame::{FrameCtx, InteractDeclaration};
7use crate::hit_test::Sense;
8use crate::input::{FrameInstant, NamedKey};
9use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
10use crate::strings::StringKey;
11use crate::theme::{Border, Color, Step12, StrokeWidth};
12use crate::widget_id::{WidgetId, WidgetKey};
13
14use super::keys::{TakeKey, take_key};
15use super::paint::{GlyphMark, IconTint, LabelText, WidgetPaint};
16use super::visuals::push_focus_ring;
17
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub enum ToastKind {
20 Info,
21 Success,
22 Warning,
23 Danger,
24}
25
26#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
27pub struct ToastState {
28 pub spawned_at: Option<FrameInstant>,
29 pub dismissed: bool,
30}
31
32impl ToastState {
33 #[must_use]
34 pub const fn fresh() -> Self {
35 Self {
36 spawned_at: None,
37 dismissed: false,
38 }
39 }
40}
41
42#[derive(Debug, PartialEq)]
43pub struct Toast<'state> {
44 pub id: WidgetId,
45 pub rect: LayoutRect,
46 pub kind: ToastKind,
47 pub message: StringKey,
48 pub dismissible: bool,
49 pub ttl: Duration,
50 pub state: &'state mut ToastState,
51}
52
53impl<'state> Toast<'state> {
54 #[must_use]
55 pub fn new(
56 id: WidgetId,
57 rect: LayoutRect,
58 kind: ToastKind,
59 message: StringKey,
60 state: &'state mut ToastState,
61 ) -> Self {
62 Self {
63 id,
64 rect,
65 kind,
66 message,
67 dismissible: true,
68 ttl: Duration::from_secs(4),
69 state,
70 }
71 }
72
73 #[must_use]
74 pub fn ttl(self, ttl: Duration) -> Self {
75 Self { ttl, ..self }
76 }
77
78 #[must_use]
79 pub fn dismissible(self, dismissible: bool) -> Self {
80 Self {
81 dismissible,
82 ..self
83 }
84 }
85}
86
87#[derive(Clone, Debug, PartialEq)]
88pub struct ToastResponse {
89 pub visible: bool,
90 pub dismissed_now: bool,
91 pub paint: Vec<WidgetPaint>,
92}
93
94const CLOSE_PX: f32 = 14.0;
95const CLOSE_GAP: f32 = 8.0;
96const TOAST_PADDING: f32 = 12.0;
97
98#[must_use]
99pub fn show_toast(ctx: &mut FrameCtx<'_>, toast: Toast<'_>) -> ToastResponse {
100 let Toast {
101 id,
102 rect,
103 kind,
104 message,
105 dismissible,
106 ttl,
107 state,
108 } = toast;
109 let now = ctx.input.frame;
110 if state.spawned_at.is_none() {
111 state.spawned_at = Some(now);
112 }
113 let was_dismissed = state.dismissed;
114 let aged_out = state.spawned_at.is_some_and(|t| now.since(t) >= ttl);
115 if aged_out {
116 state.dismissed = true;
117 }
118 let mut paint = Vec::new();
119 if was_dismissed {
120 return ToastResponse {
121 visible: false,
122 dismissed_now: false,
123 paint,
124 };
125 }
126 if aged_out {
127 return ToastResponse {
128 visible: false,
129 dismissed_now: true,
130 paint,
131 };
132 }
133 ctx.a11y
134 .push(id, rect, AccessNode::new(Role::Alert).with_label(message));
135 paint.push(WidgetPaint::Surface {
136 rect,
137 fill: surface_fill(ctx, kind),
138 border: Some(Border {
139 width: StrokeWidth::HAIRLINE,
140 color: border_color(ctx, kind),
141 }),
142 radius: ctx.theme().radius.md,
143 elevation: Some(ctx.theme().elevation.level1),
144 });
145 paint.push(leading_mark(
146 leading_mark_rect(rect),
147 kind,
148 leading_mark_color(ctx, kind),
149 ));
150 paint.push(WidgetPaint::Label {
151 rect: message_rect(rect, dismissible),
152 text: LabelText::Key(message),
153 color: ctx.theme().colors.text_primary(),
154 role: ctx.theme().typography.body,
155 });
156 let dismissed_now = dismissible && draw_close_button(ctx, id, rect, state, &mut paint);
157 ToastResponse {
158 visible: true,
159 dismissed_now,
160 paint,
161 }
162}
163
164fn draw_close_button(
165 ctx: &mut FrameCtx<'_>,
166 id: WidgetId,
167 rect: LayoutRect,
168 state: &mut ToastState,
169 paint: &mut Vec<WidgetPaint>,
170) -> bool {
171 let close_id = id.child(WidgetKey::new("close"));
172 let close_rect = close_button_rect(rect);
173 let interaction = ctx.interact(
174 InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE)
175 .focusable(true)
176 .a11y(AccessNode::new(Role::Button).with_label(StringKey::new("toast.close"))),
177 );
178 let live_focused = ctx.is_focused(close_id);
179 let key_activated = live_focused
180 && take_key(
181 ctx.input,
182 &[
183 TakeKey::named(NamedKey::Enter),
184 TakeKey::named(NamedKey::Space),
185 ],
186 )
187 .is_some();
188 let dismissed_now = interaction.click() || key_activated;
189 if dismissed_now {
190 state.dismissed = true;
191 }
192 paint.push(WidgetPaint::Surface {
193 rect: close_rect,
194 fill: if interaction.hover() {
195 ctx.theme().colors.neutral.step(Step12::HOVER_BG)
196 } else {
197 Color::TRANSPARENT
198 },
199 border: None,
200 radius: ctx.theme().radius.sm,
201 elevation: None,
202 });
203 paint.push(WidgetPaint::Icon {
204 rect: close_rect,
205 icon: IconId::Cross,
206 tint: IconTint::Solid(ctx.theme().colors.text_secondary()),
207 });
208 push_focus_ring(ctx, paint, close_rect, ctx.theme().radius.sm, live_focused);
209 dismissed_now
210}
211
212fn surface_fill(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color {
213 let scale = match kind {
214 ToastKind::Info => ctx.theme().colors.info,
215 ToastKind::Success => ctx.theme().colors.success,
216 ToastKind::Warning => ctx.theme().colors.warning,
217 ToastKind::Danger => ctx.theme().colors.danger,
218 };
219 scale.step(Step12::SUBTLE_BG)
220}
221
222fn border_color(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color {
223 let scale = match kind {
224 ToastKind::Info => ctx.theme().colors.info,
225 ToastKind::Success => ctx.theme().colors.success,
226 ToastKind::Warning => ctx.theme().colors.warning,
227 ToastKind::Danger => ctx.theme().colors.danger,
228 };
229 scale.step(Step12::BORDER)
230}
231
232fn leading_mark_color(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color {
233 let scale = match kind {
234 ToastKind::Info => ctx.theme().colors.info,
235 ToastKind::Success => ctx.theme().colors.success,
236 ToastKind::Warning => ctx.theme().colors.warning,
237 ToastKind::Danger => ctx.theme().colors.danger,
238 };
239 scale.step(Step12::SOLID)
240}
241
242fn leading_mark(rect: LayoutRect, kind: ToastKind, color: Color) -> WidgetPaint {
243 let icon = match kind {
244 ToastKind::Info | ToastKind::Success => Some(IconId::Check),
245 ToastKind::Danger => Some(IconId::Cross),
246 ToastKind::Warning => None,
247 };
248 match icon {
249 Some(icon) => WidgetPaint::Icon {
250 rect,
251 icon,
252 tint: IconTint::Solid(color),
253 },
254 None => WidgetPaint::Mark {
255 rect,
256 kind: GlyphMark::Indeterminate,
257 color,
258 },
259 }
260}
261
262fn leading_mark_rect(toast: LayoutRect) -> LayoutRect {
263 let pad = (toast.size.height.value() - CLOSE_PX) / 2.0;
264 LayoutRect::new(
265 LayoutPos::new(
266 LayoutPx::new(toast.origin.x.value() + TOAST_PADDING),
267 LayoutPx::new(toast.origin.y.value() + pad),
268 ),
269 LayoutSize::new(LayoutPx::new(CLOSE_PX), LayoutPx::new(CLOSE_PX)),
270 )
271}
272
273fn message_rect(toast: LayoutRect, has_close: bool) -> LayoutRect {
274 let leading = TOAST_PADDING + CLOSE_PX + CLOSE_GAP;
275 let trailing = if has_close {
276 TOAST_PADDING + CLOSE_PX
277 } else {
278 TOAST_PADDING
279 };
280 LayoutRect::new(
281 LayoutPos::new(
282 LayoutPx::new(toast.origin.x.value() + leading),
283 toast.origin.y,
284 ),
285 LayoutSize::new(
286 LayoutPx::saturating_nonneg(toast.size.width.value() - leading - trailing),
287 toast.size.height,
288 ),
289 )
290}
291
292fn close_button_rect(toast: LayoutRect) -> LayoutRect {
293 let pad = (toast.size.height.value() - CLOSE_PX) / 2.0;
294 LayoutRect::new(
295 LayoutPos::new(
296 LayoutPx::new(
297 toast.origin.x.value() + toast.size.width.value() - CLOSE_PX - TOAST_PADDING,
298 ),
299 LayoutPx::new(toast.origin.y.value() + pad),
300 ),
301 LayoutSize::new(LayoutPx::new(CLOSE_PX), LayoutPx::new(CLOSE_PX)),
302 )
303}
304
305#[cfg(test)]
306mod tests {
307 use core::time::Duration;
308 use std::sync::Arc;
309
310 use super::{Toast, ToastKind, ToastState, show_toast};
311 use crate::focus::FocusManager;
312 use crate::frame::FrameCtx;
313 use crate::hit_test::{HitFrame, HitState, resolve};
314 use crate::hotkey::HotkeyTable;
315 use crate::input::{
316 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
317 };
318 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
319 use crate::strings::{StringKey, StringTable};
320 use crate::theme::Theme;
321 use crate::widget_id::{WidgetId, WidgetKey};
322
323 fn toast_id() -> WidgetId {
324 WidgetId::ROOT.child(WidgetKey::new("toast"))
325 }
326
327 fn rect() -> LayoutRect {
328 LayoutRect::new(
329 LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(560.0)),
330 LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(48.0)),
331 )
332 }
333
334 fn render(
335 state: &mut ToastState,
336 focus: &mut FocusManager,
337 snap: &mut InputSnapshot,
338 prev: &HitState,
339 ttl_ms: u64,
340 ) -> (super::ToastResponse, HitState) {
341 let theme = Arc::new(Theme::light());
342 let table = HotkeyTable::new();
343 let mut hits = HitFrame::new();
344 let response = {
345 let mut shaper = bone_text::Shaper::new();
346 let mut a11y = crate::a11y::AccessTreeBuilder::new();
347 let mut ctx = FrameCtx::new(
348 theme,
349 snap,
350 focus,
351 &table,
352 StringTable::empty(),
353 &mut hits,
354 prev,
355 &mut a11y,
356 &mut shaper,
357 );
358 show_toast(
359 &mut ctx,
360 Toast::new(
361 toast_id(),
362 rect(),
363 ToastKind::Info,
364 StringKey::new("toast.msg"),
365 state,
366 )
367 .ttl(Duration::from_millis(ttl_ms)),
368 )
369 };
370 let next = resolve(prev, &hits, snap, focus.focused());
371 (response, next)
372 }
373
374 #[test]
375 fn first_frame_seeds_spawn_time() {
376 let mut state = ToastState::fresh();
377 let mut focus = FocusManager::new();
378 let mut snap = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(100)));
379 let prev = HitState::new();
380 let _ = render(&mut state, &mut focus, &mut snap, &prev, 4000);
381 assert_eq!(
382 state.spawned_at,
383 Some(FrameInstant::from_duration(Duration::from_millis(100))),
384 );
385 }
386
387 #[test]
388 fn toast_auto_dismisses_after_ttl() {
389 let mut state = ToastState::fresh();
390 let mut focus = FocusManager::new();
391 let prev = HitState::new();
392 let mut snap = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(0)));
393 let _ = render(&mut state, &mut focus, &mut snap, &prev, 1000);
394 let mut snap_late =
395 InputSnapshot::idle(FrameInstant::from_duration(Duration::from_secs(2)));
396 let (response, _) = render(&mut state, &mut focus, &mut snap_late, &prev, 1000);
397 assert!(state.dismissed);
398 assert!(!response.visible);
399 }
400
401 #[test]
402 fn enter_on_focused_close_dismisses_toast() {
403 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
404
405 let close_id = toast_id().child(WidgetKey::new("close"));
406 let mut state = ToastState::fresh();
407 let mut focus = FocusManager::new();
408 let prev = HitState::new();
409 focus.request_focus(close_id);
410 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
411 let _ = render(&mut state, &mut focus, &mut warm, &prev, 4000);
412 assert_eq!(focus.focused(), Some(close_id));
413
414 let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
415 enter.keys_pressed.push(KeyEvent::new(
416 KeyCode::Named(NamedKey::Enter),
417 ModifierMask::NONE,
418 ));
419 let _ = render(&mut state, &mut focus, &mut enter, &prev, 4000);
420 assert!(
421 state.dismissed,
422 "Enter on focused close button must dismiss"
423 );
424 }
425
426 #[test]
427 fn click_close_dismisses_toast() {
428 let mut state = ToastState::fresh();
429 let mut focus = FocusManager::new();
430 let mut prev = HitState::new();
431 let close_x = rect().origin.x.value() + rect().size.width.value() - 12.0 - 7.0;
432 let close_y = rect().origin.y.value() + rect().size.height.value() / 2.0;
433 let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(close_y));
434 [press(close_pos), release(close_pos), idle(close_pos)]
435 .into_iter()
436 .for_each(|mut snap| {
437 let (_, next) = render(&mut state, &mut focus, &mut snap, &prev, 4000);
438 prev = next;
439 });
440 assert!(state.dismissed);
441 }
442
443 fn press(pos: LayoutPos) -> InputSnapshot {
444 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
445 s.pointer = Some(PointerSample::new(pos));
446 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
447 s
448 }
449
450 fn release(pos: LayoutPos) -> InputSnapshot {
451 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
452 s.pointer = Some(PointerSample::new(pos));
453 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
454 s
455 }
456
457 fn idle(pos: LayoutPos) -> InputSnapshot {
458 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
459 s.pointer = Some(PointerSample::new(pos));
460 s
461 }
462}