use std::time::Duration;
use frust::authoring::Role;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, LayoutCtx, PaintCtx, PaintScene,
SemanticsCtx, ThemeTextType, View, Widget, WindowEdgeInsets, any,
};
use frust::{Curve, FrameTime};
use frust::{StatusPalette, Theme};
use kurbo::{Affine, Point, Size};
use peniko::Color;
use frust::Timing;
use frust::{TransitionDriver, make_driver};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ToastVariant {
#[default]
Plain,
Info,
Success,
Warning,
Error,
}
const MIN_HEIGHT: f64 = 44.0;
const PAD_X: f64 = 14.0;
const PAD_Y: f64 = 10.0;
const DOT_GAP: f64 = 10.0;
const DOT_DIAMETER: f64 = 8.0;
const FALLBACK_RADIUS: f64 = 10.0;
const FALLBACK_SURFACE: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
const FALLBACK_PLAIN: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
const FALLBACK_INFO: Color = Color::from_rgb8(0x5e, 0xc8, 0xd8);
const FALLBACK_SUCCESS: Color = Color::from_rgb8(0x5f, 0xd8, 0x8f);
const FALLBACK_WARNING: Color = Color::from_rgb8(0xf5, 0xc8, 0x60);
const FALLBACK_ERROR: Color = Color::from_rgb8(0xff, 0x6b, 0x6b);
const FALLBACK_SHADOW_Y: f64 = 12.0;
const FALLBACK_SHADOW_BLUR: f64 = 32.0;
const FALLBACK_SHADOW_ALPHA: f32 = 0.40;
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
pub struct ToastView {
message: String,
variant: ToastVariant,
}
pub fn toast(message: impl Into<String>) -> ToastView {
ToastView {
message: message.into(),
variant: ToastVariant::Plain,
}
}
#[allow(non_snake_case)]
pub fn Toast(message: impl Into<String>) -> ToastView {
toast(message)
}
impl ToastView {
pub fn variant(mut self, variant: ToastVariant) -> Self {
self.variant = variant;
self
}
}
fn message_view<State: 'static>(message: String) -> AnyView<State> {
any::<State, _>(frust::text(message).themed_family(ThemeTextType::BodyMedium))
}
impl<State: 'static> View<State> for ToastView {
type Element = ToastWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ToastWidget {
ToastWidget {
message: frust::authoring::build_child::<State>(
&message_view::<State>(self.message.clone()),
ctx,
),
message_text: self.message.clone(),
variant: self.variant,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ToastWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if prev.message != self.message {
element.message_text = self.message.clone();
let prev_view = message_view::<State>(prev.message.clone());
let next_view = message_view::<State>(self.message.clone());
flags |=
frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.message, ctx);
}
if prev.variant != self.variant {
element.variant = self.variant;
flags |= ChangeFlags::PAINT;
}
flags
}
fn teardown(&self, element: &mut ToastWidget, ctx: &mut BuildCtx<'_>) {
let view = message_view::<State>(self.message.clone());
frust::authoring::teardown_child(&view, &mut element.message, ctx);
}
}
pub struct ToastWidget {
message: ChildPod,
message_text: String,
variant: ToastVariant,
}
impl ToastWidget {
fn resolve_dot_color(theme: Option<&Theme>, variant: ToastVariant) -> Color {
match theme {
Some(t) => match variant {
ToastVariant::Plain => t.scheme().primary,
ToastVariant::Error => t.scheme().error,
ToastVariant::Info | ToastVariant::Success | ToastVariant::Warning => t
.extension::<StatusPalette>()
.map(|p| {
let c = p.colors(t.brightness);
match variant {
ToastVariant::Info => c.info,
ToastVariant::Success => c.success,
ToastVariant::Warning => c.warning,
_ => unreachable!(),
}
})
.unwrap_or(t.scheme().primary),
},
None => match variant {
ToastVariant::Plain => FALLBACK_PLAIN,
ToastVariant::Info => FALLBACK_INFO,
ToastVariant::Success => FALLBACK_SUCCESS,
ToastVariant::Warning => FALLBACK_WARNING,
ToastVariant::Error => FALLBACK_ERROR,
},
}
}
fn resolve_surface_color(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_highest,
None => FALLBACK_SURFACE,
}
}
fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
match theme {
Some(t) => {
let shadow = t.elevation.level4.shadow(t.brightness);
(
shadow.y_offset,
shadow.blur_std_dev,
with_alpha(t.scheme().shadow, shadow.color_alpha),
)
}
None => (
FALLBACK_SHADOW_Y,
FALLBACK_SHADOW_BLUR,
with_alpha(Color::from_rgb8(0, 0, 0), FALLBACK_SHADOW_ALPHA),
),
}
}
}
impl Widget for ToastWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let inner_max = Size::new(f64::INFINITY, f64::INFINITY);
let text_size = self
.message
.layout_child(ctx, &BoxConstraints::loose(inner_max));
let content_h = text_size.height.max(DOT_DIAMETER);
let height = MIN_HEIGHT.max(content_h + PAD_Y * 2.0);
self.message.set_origin(Point::new(
PAD_X + DOT_DIAMETER + DOT_GAP,
(height - text_size.height) / 2.0,
));
let width = PAD_X * 2.0 + DOT_DIAMETER + DOT_GAP + text_size.width;
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let surface = Self::resolve_surface_color(theme);
let radius = theme.map(|t| t.shape.medium).unwrap_or(FALLBACK_RADIUS);
let (shadow_y, shadow_blur, shadow_color) = Self::resolve_shadow(theme);
let size = ctx.size();
let origin = ctx.origin();
scene.draw_shadow(
Point::new(origin.x, origin.y + shadow_y),
size,
radius,
shadow_blur,
shadow_color,
);
scene.fill_rounded_rect(origin, size, radius, surface);
let dot_color = Self::resolve_dot_color(theme, self.variant);
let dot_cy = origin.y + size.height / 2.0;
let dot_cx = origin.x + PAD_X + DOT_DIAMETER / 2.0;
scene.fill_rounded_rect(
Point::new(dot_cx - DOT_DIAMETER / 2.0, dot_cy - DOT_DIAMETER / 2.0),
Size::new(DOT_DIAMETER, DOT_DIAMETER),
DOT_DIAMETER / 2.0,
dot_color,
);
self.message.paint_child(ctx, scene);
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Status, |node| {
node.set_label(self.message_text.as_str());
});
}
frust::authoring::visit_children!(message);
}
pub fn show(queue: &mut Vec<String>, message: impl Into<String>) {
queue.push(message.into());
}
const ENTER_DURATION: Duration = Duration::from_millis(220);
const ENTER_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const EXIT_DURATION: Duration = Duration::from_millis(150);
const EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const HOLD_DURATION: Duration = Duration::from_millis(2400);
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
const ENTER_OFFSET_FRACTION: f64 = 1.4;
fn resolve_timings(reduce_motion: bool) -> (Timing, Timing) {
if reduce_motion {
let t = Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
(t, t)
} else {
(
Timing::Duration(ENTER_DURATION, ENTER_CURVE),
Timing::Duration(EXIT_DURATION, EXIT_CURVE),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToastPhase {
Enter,
Hold,
Exit,
Done,
}
struct ActiveToast {
message: String,
child: ChildPod,
phase: ToastPhase,
driver: Option<TransitionDriver>,
hold_elapsed: Duration,
last_time: Option<FrameTime>,
}
impl frust::authoring::VisitPods for ActiveToast {
fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
visitor(&self.child);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ToastAnchor {
TopLeft,
TopCenter,
TopRight,
BottomLeft,
#[default]
BottomCenter,
BottomRight,
}
impl ToastAnchor {
pub fn is_top(self) -> bool {
matches!(
self,
ToastAnchor::TopLeft | ToastAnchor::TopCenter | ToastAnchor::TopRight
)
}
}
const EDGE_MARGIN: f64 = 10.0;
fn anchor_origin(
anchor: ToastAnchor,
host_size: Size,
toast_size: Size,
insets: WindowEdgeInsets,
) -> Point {
let x = match anchor {
ToastAnchor::TopLeft | ToastAnchor::BottomLeft => EDGE_MARGIN + insets.left,
ToastAnchor::TopCenter | ToastAnchor::BottomCenter => {
(host_size.width - toast_size.width) / 2.0
}
ToastAnchor::TopRight | ToastAnchor::BottomRight => {
host_size.width - EDGE_MARGIN - insets.right - toast_size.width
}
};
let y = if anchor.is_top() {
EDGE_MARGIN + insets.top
} else {
host_size.height - EDGE_MARGIN - insets.bottom - toast_size.height
};
Point::new(x, y)
}
pub struct ToastHostView {
pending: Vec<String>,
anchor: ToastAnchor,
}
pub fn toast_host(pending: impl Into<Vec<String>>) -> ToastHostView {
ToastHostView {
pending: pending.into(),
anchor: ToastAnchor::default(),
}
}
impl ToastHostView {
pub fn anchor(mut self, anchor: ToastAnchor) -> Self {
self.anchor = anchor;
self
}
}
#[allow(non_snake_case)]
pub fn ToastHost(pending: impl Into<Vec<String>>) -> ToastHostView {
toast_host(pending)
}
impl<State: 'static> View<State> for ToastHostView {
type Element = ToastHostWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ToastHostWidget {
let mut widget = ToastHostWidget {
queue: self.pending.clone(),
next_index: 0,
active: None,
anchor: self.anchor,
};
let _ = widget.maybe_advance_queue::<State>(ctx);
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut ToastHostWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.queue = self.pending.clone();
element.next_index = element.next_index.min(element.queue.len());
let anchor_changed = prev.anchor != self.anchor;
element.anchor = self.anchor;
let structural_change = element.maybe_advance_queue::<State>(ctx);
if anchor_changed || structural_change {
ChangeFlags::LAYOUT | ChangeFlags::PAINT
} else {
ChangeFlags::PAINT
}
}
fn teardown(&self, element: &mut ToastHostWidget, ctx: &mut BuildCtx<'_>) {
if let Some(active) = element.active.take() {
let view = any::<State, _>(toast(active.message));
let mut pod = active.child;
frust::authoring::teardown_child(&view, &mut pod, ctx);
}
}
}
pub struct ToastHostWidget {
queue: Vec<String>,
next_index: usize,
active: Option<ActiveToast>,
anchor: ToastAnchor,
}
impl ToastHostWidget {
fn maybe_advance_queue<State: 'static>(&mut self, ctx: &mut BuildCtx<'_>) -> bool {
let mut structural_change = false;
if matches!(
self.active.as_ref().map(|a| a.phase),
Some(ToastPhase::Done)
) {
let active = self.active.take().expect("checked Some above");
let view = any::<State, _>(toast(active.message));
let mut pod = active.child;
frust::authoring::teardown_child(&view, &mut pod, ctx);
self.next_index += 1;
structural_change = true;
}
if self.active.is_none()
&& let Some(message) = self.queue.get(self.next_index).cloned()
{
let child = frust::authoring::build_child::<State>(
&any::<State, _>(toast(message.clone())),
ctx,
);
self.active = Some(ActiveToast {
message,
child,
phase: ToastPhase::Enter,
driver: None,
hold_elapsed: Duration::ZERO,
last_time: None,
});
structural_change = true;
}
structural_change
}
fn advance(&mut self, now: FrameTime, enter: Timing, exit: Timing) -> Option<f64> {
let active = self.active.as_mut()?;
let shown = match active.phase {
ToastPhase::Enter => {
let adv = active
.driver
.get_or_insert_with(|| make_driver(enter).0)
.advance(now);
if adv.done {
active.phase = ToastPhase::Hold;
active.driver = None;
active.last_time = Some(now);
}
adv.value
}
ToastPhase::Hold => {
if let Some(last) = active.last_time {
active.hold_elapsed += now.saturating_sub(last);
}
active.last_time = Some(now);
if active.hold_elapsed >= HOLD_DURATION {
active.phase = ToastPhase::Exit;
active.driver = None;
}
1.0
}
ToastPhase::Exit => {
let adv = active
.driver
.get_or_insert_with(|| make_driver(exit).0)
.advance(now);
if adv.done {
active.phase = ToastPhase::Done;
}
1.0 - adv.value.clamp(0.0, 1.0)
}
ToastPhase::Done => 0.0,
};
Some(shown)
}
}
impl Widget for ToastHostWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let toast_size = match &mut self.active {
Some(active) if !matches!(active.phase, ToastPhase::Done) => {
Some(active.child.layout_child(ctx, &bc.loosen()))
}
_ => None,
};
let natural = toast_size.unwrap_or(Size::ZERO);
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
natural.width
};
let height = if bc.max().height.is_finite() {
bc.max().height
} else {
natural.height
};
let size = bc.constrain(Size::new(width, height));
if let (Some(active), Some(toast_size)) = (&mut self.active, toast_size) {
let insets = ctx.window_insets().padding();
let origin = anchor_origin(self.anchor, size, toast_size, insets);
active.child.set_origin(origin);
}
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let (enter, exit) = resolve_timings(reduce_motion);
let now = ctx.frame_time();
let host_origin = ctx.origin();
let sign = if self.anchor.is_top() { -1.0 } else { 1.0 };
if let Some(shown) = self.advance(now, enter, exit) {
let phase = self.active.as_ref().map(|a| a.phase);
let done = matches!(phase, Some(ToastPhase::Done));
if !done && let Some(active) = &mut self.active {
let toast_origin = host_origin + active.child.origin().to_vec2();
let toast_size = active.child.size();
let dy = (1.0 - shown) * sign * (ENTER_OFFSET_FRACTION * toast_size.height);
let alpha = shown.clamp(0.0, 1.0) as f32;
scene.push_transform(Affine::translate((0.0, dy)));
scene.push_layer(toast_origin, toast_size, alpha);
active.child.paint_child(ctx, scene);
scene.pop_layer();
scene.pop_transform();
}
if matches!(phase, Some(ToastPhase::Hold)) {
ctx.request_frame_paced();
} else {
ctx.request_frame();
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if let Some(active) = &self.active
&& !matches!(active.phase, ToastPhase::Done)
{
active.child.semantics_child(ctx);
}
}
frust::authoring::visit_children!(active);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::BuildCtx;
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn build_host(pending: Vec<String>) -> ToastHostWidget {
let view: ToastHostView = toast_host(pending);
let mut counter = 0u64;
<ToastHostView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
}
#[derive(Default)]
struct RecordingScene {
rrects: Vec<(Point, Size, f64, Color)>,
shadows: Vec<(Point, Size, f64, f64, Color)>,
transforms: Vec<Affine>,
transform_pops: u32,
layers: Vec<(Point, Size, f32)>,
layer_pops: u32,
}
impl PaintScene for RecordingScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
self.shadows.push((o, s, radius, std_dev, color));
}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
fn push_layer(&mut self, origin: Point, size: Size, alpha: f32) {
self.layers.push((origin, size, alpha));
}
fn pop_layer(&mut self) {
self.layer_pops += 1;
}
}
fn build_toast(message: &str) -> ToastWidget {
let view: ToastView = toast(message);
let mut counter = 0u64;
<ToastView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
}
#[test]
fn toast_paints_shadow_surface_dot_and_message() {
use frust::authoring::LayoutCtx;
use frust::authoring::text::TextContext;
use std::any::Any;
let mut w = build_toast("Saved");
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 200.0)));
let mut ctx = PaintCtx::new(Point::ZERO, size);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(scene.shadows.len(), 1);
assert_eq!(scene.rrects.len(), 2, "surface rect + dot rect");
assert_eq!(scene.rrects[0].3, FALLBACK_SURFACE);
}
#[test]
fn variant_selects_the_dot_color_unthemed() {
for (variant, expected) in [
(ToastVariant::Plain, FALLBACK_PLAIN),
(ToastVariant::Info, FALLBACK_INFO),
(ToastVariant::Success, FALLBACK_SUCCESS),
(ToastVariant::Warning, FALLBACK_WARNING),
(ToastVariant::Error, FALLBACK_ERROR),
] {
assert_eq!(ToastWidget::resolve_dot_color(None, variant), expected);
}
}
#[test]
fn glyph_tokens_resolve_dark_and_light() {
let dark = crate::baseline();
let light = dark.clone().with_brightness(frust::Brightness::Light);
assert_eq!(
ToastWidget::resolve_surface_color(Some(&dark)),
dark.scheme().surface_container_highest
);
assert_eq!(
ToastWidget::resolve_surface_color(Some(&light)),
light.scheme().surface_container_highest
);
assert_ne!(
dark.scheme().surface_container_highest,
light.scheme().surface_container_highest
);
let dark_success = ToastWidget::resolve_dot_color(Some(&dark), ToastVariant::Success);
let light_success = ToastWidget::resolve_dot_color(Some(&light), ToastVariant::Success);
assert_eq!(
dark_success,
dark.extension::<StatusPalette>().unwrap().dark.success
);
assert_eq!(
light_success,
light.extension::<StatusPalette>().unwrap().light.success
);
}
#[test]
fn only_one_toast_is_active_at_a_time_and_starts_the_first_request() {
let host = build_host(vec!["first".to_string(), "second".to_string()]);
let active = host
.active
.as_ref()
.expect("first request starts immediately");
assert_eq!(active.message, "first");
assert_eq!(active.phase, ToastPhase::Enter);
assert_eq!(host.next_index, 0, "not yet advanced past the active item");
}
#[test]
fn finishing_a_toast_starts_the_next_queued_one_on_rebuild() {
let mut host = build_host(vec!["first".to_string(), "second".to_string()]);
let (enter, exit) = resolve_timings(false);
let mut t = 0.0;
loop {
host.advance(ft_ms(t), enter, exit);
if matches!(
host.active.as_ref().map(|a| a.phase),
Some(ToastPhase::Done)
) {
break;
}
t += 50.0;
assert!(t < 10_000.0, "timeline never reached Done");
}
let view: ToastHostView = toast_host(vec!["first".to_string(), "second".to_string()]);
let mut counter = 0u64;
<ToastHostView as View<()>>::rebuild(
&view,
&view,
&mut host,
&mut BuildCtx::new(&mut counter),
);
assert_eq!(host.next_index, 1, "advanced past the finished first toast");
let active = host.active.as_ref().expect("the second request starts");
assert_eq!(active.message, "second");
assert_eq!(active.phase, ToastPhase::Enter);
}
#[test]
fn rebuild_reports_layout_when_a_pushed_toast_starts() {
let mut host = build_host(vec![]);
assert!(host.active.is_none(), "nothing queued yet");
let prev_view: ToastHostView = toast_host(vec![]);
let next_view: ToastHostView = toast_host(vec!["hello".to_string()]);
let mut counter = 0u64;
let flags = <ToastHostView as View<()>>::rebuild(
&next_view,
&prev_view,
&mut host,
&mut BuildCtx::new(&mut counter),
);
assert!(host.active.is_some(), "the pushed toast starts immediately");
assert!(
flags.needs_layout(),
"a newly-started toast's child pod has never been laid out — \
the rebuild that builds it MUST report LAYOUT, or a \
layout-skip shell (Android) leaves it at Size::ZERO for its \
whole lifecycle"
);
}
#[test]
fn honoring_rebuild_flags_after_a_push_always_lays_out_the_new_child() {
let mut host = build_host(vec![]);
let prev_view: ToastHostView = toast_host(vec![]);
let next_view: ToastHostView = toast_host(vec!["hello".to_string()]);
let mut counter = 0u64;
let flags = <ToastHostView as View<()>>::rebuild(
&next_view,
&prev_view,
&mut host,
&mut BuildCtx::new(&mut counter),
);
if flags.needs_layout() {
layout_host(&mut host, &BoxConstraints::tight(Size::new(400.0, 800.0)));
}
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 800.0));
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
assert_eq!(scene.layers.len(), 1, "the active toast paints a layer");
let (_, layer_size, _) = scene.layers[0];
assert!(
layer_size.width > 0.0 && layer_size.height > 0.0,
"the toast layer must be nonzero — a zero size means the shell \
skipped layout on a flags contract it correctly honored, i.e. \
the bug this task fixes: {layer_size:?}"
);
}
#[test]
fn rebuild_reports_layout_when_a_finished_toast_tears_down() {
let mut host = build_host(vec!["first".to_string(), "second".to_string()]);
let (enter, exit) = resolve_timings(false);
let mut t = 0.0;
loop {
host.advance(ft_ms(t), enter, exit);
if matches!(
host.active.as_ref().map(|a| a.phase),
Some(ToastPhase::Done)
) {
break;
}
t += 50.0;
assert!(t < 10_000.0, "timeline never reached Done");
}
let view: ToastHostView = toast_host(vec!["first".to_string(), "second".to_string()]);
let mut counter = 0u64;
let flags = <ToastHostView as View<()>>::rebuild(
&view,
&view,
&mut host,
&mut BuildCtx::new(&mut counter),
);
assert!(
flags.needs_layout(),
"tearing down the finished toast and starting the next is also \
a structural change and must report LAYOUT"
);
}
#[test]
fn idle_host_with_no_pending_requests_requests_no_frame() {
let mut host = build_host(vec![]);
assert!(host.active.is_none());
let mut ctx = PaintCtx::new(Point::ZERO, Size::ZERO);
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
assert!(!ctx.needs_frame());
}
#[test]
fn timeline_progresses_enter_then_hold_then_exit_then_done() {
let mut host = build_host(vec!["hello".to_string()]);
let (enter, exit) = resolve_timings(false);
let shown0 = host.advance(ft_ms(0.0), enter, exit).unwrap();
assert!(shown0.abs() < 1e-6);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Enter);
let shown_settled = host.advance(ft_ms(300.0), enter, exit).unwrap();
assert!((shown_settled - 1.0).abs() < 1e-6);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Hold);
host.advance(ft_ms(300.0 + 2000.0), enter, exit);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Hold);
host.advance(ft_ms(300.0 + 2500.0), enter, exit);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Exit);
let shown_exit_seed = host
.advance(ft_ms(300.0 + 2500.0 + 10.0), enter, exit)
.unwrap();
assert!(
(shown_exit_seed - 1.0).abs() < 1e-6,
"exit starts fully shown"
);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Exit);
let shown_done = host
.advance(ft_ms(300.0 + 2500.0 + 10.0 + 300.0), enter, exit)
.unwrap();
assert!(shown_done.abs() < 1e-6);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Done);
}
#[test]
fn reduce_motion_uses_the_fast_linear_crossfade_timing() {
let (enter, exit) = resolve_timings(true);
assert_eq!(
enter,
Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear)
);
assert_eq!(
exit,
Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear)
);
}
#[test]
fn paint_at_enter_start_emits_translated_faded_layer() {
let mut host = build_host(vec!["hi".to_string()]);
{
use frust::authoring::LayoutCtx;
use frust::authoring::text::TextContext;
use std::any::Any;
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
host.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 100.0)));
}
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 50.0));
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
assert_eq!(scene.transforms.len(), 1);
assert_eq!(scene.transform_pops, 1);
assert_eq!(scene.layers.len(), 1);
assert_eq!(scene.layer_pops, 1);
assert_eq!(scene.layers[0].2, 0.0);
assert!(ctx.needs_frame());
}
#[test]
fn hold_phase_paces_frame_requests_but_enter_and_exit_do_not() {
let mut host = build_host(vec!["hi".to_string()]);
{
use frust::authoring::LayoutCtx;
use frust::authoring::text::TextContext;
use std::any::Any;
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
host.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 100.0)));
}
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Enter);
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 100.0));
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Enter);
assert!(
!ctx.needs_frame_paced_only(),
"Enter is a genuine transition and must run unpaced"
);
host.active.as_mut().unwrap().phase = ToastPhase::Hold;
host.active.as_mut().unwrap().last_time = None;
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 100.0));
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Hold);
assert!(
ctx.needs_frame_paced_only(),
"Hold's only need is elapsed-time bookkeeping toward \
HOLD_DURATION — it must pace to the mobile frame gate's \
cosmetic-loop rate"
);
host.active.as_mut().unwrap().phase = ToastPhase::Exit;
host.active.as_mut().unwrap().driver = None;
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 100.0));
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
assert_eq!(host.active.as_ref().unwrap().phase, ToastPhase::Exit);
assert!(
!ctx.needs_frame_paced_only(),
"Exit is a genuine transition and must run unpaced"
);
}
fn layout_host(host: &mut ToastHostWidget, bc: &BoxConstraints) -> Size {
use frust::authoring::LayoutCtx;
use frust::authoring::text::TextContext;
use std::any::Any;
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
host.layout(&mut lctx, bc)
}
#[test]
fn default_anchor_is_bottom_center() {
assert_eq!(ToastAnchor::default(), ToastAnchor::BottomCenter);
}
#[test]
fn default_anchor_positions_bottom_center_with_margin_no_insets() {
let mut host = build_host(vec!["hi".to_string()]);
let size = layout_host(&mut host, &BoxConstraints::tight(Size::new(400.0, 800.0)));
assert_eq!(size, Size::new(400.0, 800.0), "host fills the full bc");
let active = host.active.as_ref().expect("toast starts immediately");
let toast_size = active.child.size();
let origin = active.child.origin();
let center_x = origin.x + toast_size.width / 2.0;
assert!(
(center_x - 200.0).abs() < 1e-6,
"toast rect centers horizontally: {center_x}"
);
let bottom_edge = origin.y + toast_size.height;
assert!(
(bottom_edge - (800.0 - EDGE_MARGIN)).abs() < 1e-6,
"toast rect's bottom edge sits EDGE_MARGIN above the host bottom: {bottom_edge}"
);
}
#[test]
fn default_anchor_bottom_margin_additionally_respects_the_bottom_inset() {
use frust::authoring::text::TextContext;
use frust::authoring::{WindowEdgeInsets, WindowInsets};
use frust_core::RenderRoot;
fn logic(_: &mut ()) -> ToastHostView {
toast_host(vec!["hi".to_string()])
}
let mut root: RenderRoot<(), ToastHostView> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.set_insets(WindowInsets::new(
WindowEdgeInsets::new(0.0, 0.0, 0.0, 34.0),
WindowEdgeInsets::ZERO,
));
let mut tcx = TextContext::new();
let size = root.layout_with_text(Size::new(400.0, 800.0), &mut tcx);
assert_eq!(size, Size::new(400.0, 800.0));
let id = root.root_id().expect("root built");
let widget = (root.tree().pod(id).expect("root pod").widget() as &dyn std::any::Any)
.downcast_ref::<ToastHostWidget>()
.expect("root is a ToastHostWidget");
let active = widget.active.as_ref().expect("toast starts immediately");
let toast_size = active.child.size();
let origin = active.child.origin();
let bottom_edge = origin.y + toast_size.height;
assert!(
(bottom_edge - (800.0 - EDGE_MARGIN - 34.0)).abs() < 1e-6,
"the 34px bottom inset widens the margin additively: {bottom_edge}"
);
}
#[test]
fn all_six_anchors_position_correctly() {
for anchor in [
ToastAnchor::TopLeft,
ToastAnchor::TopCenter,
ToastAnchor::TopRight,
ToastAnchor::BottomLeft,
ToastAnchor::BottomCenter,
ToastAnchor::BottomRight,
] {
let view: ToastHostView = toast_host(vec!["hi".to_string()]).anchor(anchor);
let mut counter = 0u64;
let mut host =
<ToastHostView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter));
let size = layout_host(&mut host, &BoxConstraints::tight(Size::new(400.0, 800.0)));
assert_eq!(size, Size::new(400.0, 800.0));
let active = host.active.as_ref().expect("toast starts immediately");
let toast_size = active.child.size();
let origin = active.child.origin();
let expected_x = match anchor {
ToastAnchor::TopLeft | ToastAnchor::BottomLeft => EDGE_MARGIN,
ToastAnchor::TopCenter | ToastAnchor::BottomCenter => {
(400.0 - toast_size.width) / 2.0
}
ToastAnchor::TopRight | ToastAnchor::BottomRight => {
400.0 - EDGE_MARGIN - toast_size.width
}
};
let expected_y = if anchor.is_top() {
EDGE_MARGIN
} else {
800.0 - EDGE_MARGIN - toast_size.height
};
assert!(
(origin.x - expected_x).abs() < 1e-6,
"{anchor:?}: x = {}, expected {expected_x}",
origin.x
);
assert!(
(origin.y - expected_y).abs() < 1e-6,
"{anchor:?}: y = {}, expected {expected_y}",
origin.y
);
}
}
#[test]
fn host_never_swallows_events_inside_or_outside_the_toast_rect() {
use frust::authoring::{
EventCtx, EventResult, InputEvent, PointerButton, PointerEvent, PointerPhase,
};
use std::any::Any;
let mut host = build_host(vec!["hi".to_string()]);
let mut state = ();
let state_any: &mut dyn Any = &mut state;
let mut ectx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 800.0));
let ev = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: Point::new(5.0, 5.0),
button: PointerButton::Primary,
});
assert_eq!(host.event(&mut ectx, &ev), EventResult::Ignored);
}
#[test]
fn inspect_surfaces_the_active_toast_only_while_one_is_playing() {
use frust::authoring::text::TextContext;
use frust_core::{InspectNode, RenderRoot};
use std::any::Any;
fn short_names(nodes: &[InspectNode]) -> Vec<&str> {
nodes
.iter()
.map(|n| {
let bare = n.type_name.split('<').next().unwrap_or(n.type_name);
bare.rsplit("::").next().unwrap_or(bare)
})
.collect()
}
fn idle_logic(_: &mut ()) -> ToastHostView {
toast_host(Vec::<String>::new())
}
let mut idle_root: RenderRoot<(), ToastHostView> = RenderRoot::new();
let mut state = ();
idle_root.rebuild(&mut idle_logic, &mut state);
let mut idle_tcx = TextContext::new();
idle_root.layout_with_text(Size::new(400.0, 400.0), &mut idle_tcx as &mut dyn Any);
assert_eq!(short_names(&idle_root.inspect()), vec!["ToastHostWidget"]);
fn playing_logic(_: &mut ()) -> ToastHostView {
toast_host(vec!["hello".to_string()])
}
let mut playing_root: RenderRoot<(), ToastHostView> = RenderRoot::new();
playing_root.rebuild(&mut playing_logic, &mut state);
let mut playing_tcx = TextContext::new();
playing_root.layout_with_text(Size::new(400.0, 400.0), &mut playing_tcx as &mut dyn Any);
assert_eq!(
short_names(&playing_root.inspect()),
vec!["ToastHostWidget", "ToastWidget", "TextWidget"],
"the active toast (and its own child) hang under the host"
);
}
#[test]
fn enter_offset_sign_flips_between_top_and_bottom_anchors() {
fn early_enter_dy(anchor: ToastAnchor) -> f64 {
let view: ToastHostView = toast_host(vec!["hi".to_string()]).anchor(anchor);
let mut counter = 0u64;
let mut host =
<ToastHostView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter));
layout_host(&mut host, &BoxConstraints::tight(Size::new(400.0, 800.0)));
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 800.0));
let mut scene = RecordingScene::default();
host.paint(&mut ctx, &mut scene);
scene.transforms[0].translation().y
}
let bottom_dy = early_enter_dy(ToastAnchor::BottomCenter);
let top_dy = early_enter_dy(ToastAnchor::TopCenter);
assert!(
bottom_dy > 0.0,
"a bottom anchor rises from below (positive dy): {bottom_dy}"
);
assert!(
top_dy < 0.0,
"a top anchor drops from above (negative dy): {top_dy}"
);
}
#[cfg(feature = "bundled-fonts")]
fn saved(_: &mut ()) -> ToastView {
toast("Saved")
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn the_message_paints_in_its_role_face_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(saved, crate::baseline(), Size::new(400.0, 200.0));
assert_eq!(faces, [Face::PlexMono]);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn the_message_follows_a_live_theme_family_swap() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(saved, Size::new(400.0, 200.0));
assert_eq!(before, [Face::PlexMono]);
assert_eq!(after, [Face::SpaceMono]);
}
}