use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::Role;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust::input::{TOUCH_SLOP, VelocityTracker};
use kurbo::{Point, Rect, RoundedRect, RoundedRectRadii, Shape, Size};
use peniko::{Brush, Color};
use frust::{
AnimationController, BackPolicy, FrameTime, NavigatorController, PopResult, PushOptions,
SpringDesc, TransitionSpec,
};
use super::press::presses;
use crate::overlay::modal::{ModalDismiss, StagedPop};
use crate::overlay::{OverlayContainer, container, on_surface_variant, radius, scrim};
use crate::tokens::MaterialSpring;
const HANDLE_WIDTH: f64 = 32.0;
const HANDLE_HEIGHT: f64 = 4.0;
const HANDLE_TOUCH_TARGET: f64 = 48.0;
const PATH_TOLERANCE: f64 = 0.1;
const DRAG_DISMISS_FRACTION: f64 = 0.5;
const FLING_VELOCITY: f64 = 400.0;
const SPRING_KICK: f64 = 4.0;
const SETTLED_EPSILON: f64 = 1e-3;
const SHEET_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio,
};
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
fn scale_alpha(color: Color, factor: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], c[3] * factor])
}
type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
type OnClose = Rc<StagedPop>;
pub struct BottomSheetView<State: 'static> {
content: AnyView<State>,
on_dismiss: Option<OnDismiss<State>>,
dismissable: bool,
on_close: Option<OnClose>,
dismiss_signal: Option<Rc<Cell<u64>>>,
dismiss_handle: Option<ModalDismiss>,
}
pub fn bottom_sheet<State: 'static, V: View<State>>(content: V) -> BottomSheetView<State> {
BottomSheetView {
content: any(content),
on_dismiss: None,
dismissable: true,
on_close: None,
dismiss_signal: None,
dismiss_handle: None,
}
}
impl<State: 'static> BottomSheetView<State> {
pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
self.on_dismiss = Some(Rc::new(on_dismiss));
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
self.dismiss_handle = Some(dismiss);
self
}
}
pub fn show_bottom_sheet<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) where
State: 'static,
B: Fn() -> BottomSheetView<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let dismiss_ctrl = controller.clone();
let staged: OnClose = Rc::new(StagedPop::navigator_pop(controller));
let dismissable = build().dismissable;
let signal = dismissable.then(|| Rc::new(Cell::new(0u64)));
let widget_signal = signal.clone();
let mut options = PushOptions::transparent()
.transition(TransitionSpec::NONE)
.back(if dismissable {
BackPolicy::DismissAnimated
} else {
BackPolicy::Veto
})
.on_result(on_result);
if let Some(sig) = &signal {
options = options.dismiss_signal(sig.clone());
}
controller.push_with_options(
move || {
let ctrl = dismiss_ctrl.clone();
let mut view = build().on_dismiss(move |_state: &mut State| ctrl.pop());
view.on_close = Some(staged.clone());
view.dismiss_signal = widget_signal.clone();
any::<State, _>(view)
},
options,
);
}
pub struct BottomSheetWidget {
content: ChildPod,
on_dismiss: Option<frust::authoring::ErasedCallback>,
dismissable: bool,
on_close: Option<OnClose>,
dismiss_signal: Option<Rc<Cell<u64>>>,
last_seen_dismiss: u64,
dismiss_handle: Option<ModalDismiss>,
last_seen_request: u64,
pending_result: Option<PopResult>,
exiting: bool,
closed: bool,
dismissed: bool,
content_size: Size,
panel: Rect,
handle_target: Rect,
progress: f64,
anim: AnimationController,
started: bool,
drag_captured: bool,
drag_start_y: f64,
drag_from: f64,
drag_moved: bool,
tracker: VelocityTracker,
last_frame_time: FrameTime,
scrim_captured: bool,
scrim_down_outside: bool,
}
impl<State: 'static> View<State> for BottomSheetView<State> {
type Element = BottomSheetWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> BottomSheetWidget {
BottomSheetWidget {
content: frust::authoring::build_child(&self.content, ctx),
on_dismiss: self
.on_dismiss
.as_ref()
.map(frust::authoring::erase_callback),
dismissable: self.dismissable,
on_close: self.on_close.clone(),
dismiss_signal: self.dismiss_signal.clone(),
last_seen_dismiss: self.dismiss_signal.as_ref().map_or(0, |sig| sig.get()),
dismiss_handle: self.dismiss_handle.clone(),
last_seen_request: self
.dismiss_handle
.as_ref()
.map_or(0, ModalDismiss::generation),
pending_result: None,
exiting: false,
closed: false,
dismissed: false,
content_size: Size::ZERO,
panel: Rect::ZERO,
handle_target: Rect::ZERO,
progress: 0.0,
anim: AnimationController::new(Duration::ZERO),
started: false,
drag_captured: false,
drag_start_y: 0.0,
drag_from: 0.0,
drag_moved: false,
tracker: VelocityTracker::new(),
last_frame_time: FrameTime::ZERO,
scrim_captured: false,
scrim_down_outside: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut BottomSheetWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let flags = frust::authoring::rebuild_child(
&prev.content,
&self.content,
&mut element.content,
ctx,
);
element.on_dismiss = self
.on_dismiss
.as_ref()
.map(frust::authoring::erase_callback);
element.dismissable = self.dismissable;
element.on_close = self.on_close.clone();
element.dismiss_signal = self.dismiss_signal.clone();
if let Some(handle) = &self.dismiss_handle
&& !element
.dismiss_handle
.as_ref()
.is_some_and(|installed| installed.same_cell(handle))
{
element.last_seen_request = handle.generation();
element.dismiss_handle = Some(handle.clone());
}
flags
}
fn teardown(&self, element: &mut BottomSheetWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.content, &mut element.content, ctx);
}
}
impl BottomSheetWidget {
fn observe_dismiss_signal(&mut self, ctx: &mut PaintCtx) {
let Some(signal) = &self.dismiss_signal else {
return;
};
let current = signal.get();
if current == self.last_seen_dismiss {
return;
}
self.last_seen_dismiss = current;
if self.refuses_dismiss() || self.on_close.is_none() {
return;
}
self.stage_exit(-SPRING_KICK);
ctx.request_frame();
}
fn pending_dismiss_request(&self) -> Option<u64> {
let handle = self.dismiss_handle.as_ref()?;
let current = handle.generation();
(current != self.last_seen_request).then_some(current)
}
fn latch_dismiss_request(&mut self, generation: u64) {
self.last_seen_request = generation;
}
fn accept_dismiss_request(&mut self, generation: u64) {
self.latch_dismiss_request(generation);
self.pending_result = self
.dismiss_handle
.as_ref()
.and_then(|handle| handle.take_result(generation));
}
fn refuses_dismiss(&self) -> bool {
self.exiting || self.closed || self.dismissed
}
fn observe_dismiss_request(&mut self, ctx: &mut EventCtx) {
let Some(request) = self.pending_dismiss_request() else {
return;
};
if self.refuses_dismiss() {
self.latch_dismiss_request(request);
return;
}
if self.on_close.is_some() {
self.accept_dismiss_request(request);
} else {
self.latch_dismiss_request(request);
}
self.request_dismiss(ctx, -SPRING_KICK);
}
fn observe_paint_dismiss_request(&mut self, ctx: &mut PaintCtx) {
let Some(request) = self.pending_dismiss_request() else {
return;
};
if self.refuses_dismiss() {
self.latch_dismiss_request(request);
return;
}
if self.on_close.is_none() {
return;
}
self.accept_dismiss_request(request);
self.stage_exit(-SPRING_KICK);
ctx.request_frame();
}
fn fire_staged_close(&mut self, ctx: &mut PaintCtx, reduce: bool) {
if !self.exiting || self.anim.is_animating() || self.progress > SETTLED_EPSILON {
return;
}
self.exiting = false;
self.progress = 0.0;
let Some(on_close) = self.on_close.clone() else {
return;
};
if on_close.fire_with(&mut self.pending_result) {
self.closed = true;
ctx.request_frame();
return;
}
self.pending_result = None;
if reduce {
self.progress = 1.0;
} else {
self.spring_open(SPRING_KICK);
}
ctx.request_frame();
}
fn spring_to(&mut self, velocity: f64) {
let mut seed = AnimationController::new(Duration::ZERO);
seed.animate_to(self.progress);
self.anim = seed;
self.anim.fling(velocity, SHEET_SPRING);
}
fn spring_open(&mut self, velocity: f64) {
self.exiting = false;
self.pending_result = None;
self.spring_to(velocity);
}
fn stage_exit(&mut self, velocity: f64) {
self.spring_to(velocity);
self.exiting = true;
if let Some(on_close) = &self.on_close {
on_close.arm();
}
}
fn request_dismiss(&mut self, ctx: &mut EventCtx, velocity: f64) {
if self.refuses_dismiss() {
return;
}
if self.on_close.is_some() {
self.stage_exit(velocity);
ctx.request_redraw();
return;
}
self.spring_to(velocity);
if let Some(on_dismiss) = self.on_dismiss.as_mut() {
on_dismiss(ctx);
self.dismissed = true;
}
}
fn event_time_ms(&self) -> f64 {
self.last_frame_time.as_secs_f64() * 1000.0
}
fn reposition(&mut self, area: Size) {
let panel_h = (HANDLE_TOUCH_TARGET + self.content_size.height).min(area.height);
let panel_y = area.height - panel_h * self.progress;
self.panel =
Rect::from_origin_size(Point::new(0.0, panel_y), Size::new(area.width, panel_h));
self.handle_target = Rect::new(0.0, panel_y, area.width, panel_y + HANDLE_TOUCH_TARGET);
let content_x = ((area.width - self.content_size.width) / 2.0).max(0.0);
self.content
.set_origin(Point::new(content_x, panel_y + HANDLE_TOUCH_TARGET));
}
}
impl Widget for BottomSheetWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area = Size::new(
finite_or_zero(bc.max().width),
finite_or_zero(bc.max().height),
);
let content_max_h = (area.height - HANDLE_TOUCH_TARGET).max(0.0);
let content_bc = BoxConstraints::loose(Size::new(area.width, content_max_h));
self.content_size = self.content.layout_child(ctx, &content_bc);
self.reposition(area);
bc.constrain(area)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.observe_dismiss_signal(ctx);
self.observe_paint_dismiss_request(ctx);
self.last_frame_time = ctx.frame_time();
let (reduce, panel_radius, scrim_color, container_color, handle_color) = {
let theme = Theme::from_paint_ctx(ctx);
(
theme.is_some_and(|t| t.motion.reduce_motion),
radius(theme),
scrim(theme),
container(theme, OverlayContainer::Low),
on_surface_variant(theme),
)
};
if !self.started {
self.started = true;
if reduce {
self.progress = 1.0;
} else if !self.exiting {
self.spring_to(SPRING_KICK);
}
}
if reduce {
if self.anim.is_animating() {
self.anim.stop();
}
if self.exiting {
self.progress = 0.0;
}
} else if self.anim.is_animating() {
if self.anim.advance(self.last_frame_time) {
ctx.request_frame();
}
self.progress = self.anim.value();
}
self.fire_staged_close(ctx, reduce);
self.reposition(ctx.size());
scene.fill_rect(
ctx.origin(),
ctx.size(),
scale_alpha(scrim_color, self.progress.clamp(0.0, 1.0) as f32),
);
let panel_local = Rect::new(self.panel.x0, self.panel.y0, self.panel.x1, self.panel.y1);
let radii = RoundedRectRadii::new(panel_radius, panel_radius, 0.0, 0.0);
let path = RoundedRect::from_rect(panel_local, radii).to_path(PATH_TOLERANCE);
scene.fill_path(ctx.origin(), &path, &Brush::Solid(container_color));
let handle_x = self.panel.x0 + (self.panel.width() - HANDLE_WIDTH) / 2.0;
let handle_y = self.panel.y0 + (HANDLE_TOUCH_TARGET - HANDLE_HEIGHT) / 2.0;
scene.fill_rounded_rect(
Point::new(ctx.origin().x + handle_x, ctx.origin().y + handle_y),
Size::new(HANDLE_WIDTH, HANDLE_HEIGHT),
HANDLE_HEIGHT / 2.0,
handle_color,
);
self.content.paint_child(ctx, scene);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
frust::authoring::route_event_single(&mut self.content, ctx, event);
return EventResult::Ignored;
}
if self.drag_captured {
let InputEvent::Pointer(p) = event else {
return EventResult::Handled;
};
match p.phase {
PointerPhase::Move => {
self.tracker.record(self.event_time_ms(), p.position.y);
let dy = p.position.y - self.drag_start_y;
if !self.drag_moved && dy.abs() > TOUCH_SLOP {
self.drag_moved = true;
}
if self.drag_moved {
let extent = self.panel.height().max(1.0);
let target = (self.drag_from - dy / extent).clamp(0.0, 1.0);
self.progress = target;
self.exiting = false;
self.pending_result = None;
self.reposition(ctx.size());
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
self.drag_captured = false;
if !self.drag_moved {
return EventResult::Handled;
}
let velocity = self.tracker.velocity();
let extent = self.panel.height().max(1.0);
let stay_open = if velocity.abs() >= FLING_VELOCITY {
velocity < 0.0
} else {
self.progress >= DRAG_DISMISS_FRACTION
};
let raw = -velocity / extent;
if stay_open || !self.dismissable {
self.spring_open(raw.max(SPRING_KICK));
} else {
self.request_dismiss(ctx, raw.min(-SPRING_KICK));
}
EventResult::Handled
}
PointerPhase::Cancel => {
self.drag_captured = false;
if self.drag_moved {
self.spring_open(SPRING_KICK);
}
EventResult::Handled
}
PointerPhase::Down => EventResult::Handled,
}
}
else if self.scrim_captured {
let InputEvent::Pointer(p) = event else {
return EventResult::Handled;
};
match p.phase {
PointerPhase::Up => {
let released_outside = !self.panel.contains(p.position);
if self.dismissable && self.scrim_down_outside && released_outside {
self.request_dismiss(ctx, -SPRING_KICK);
}
self.scrim_captured = false;
EventResult::Handled
}
PointerPhase::Cancel => {
self.scrim_captured = false;
EventResult::Handled
}
_ => EventResult::Handled,
}
}
else {
let InputEvent::Pointer(p) = event else {
if let InputEvent::Key(key_event) = event
&& self.dismissable
&& key_event.key == Key::Named(NamedKey::Escape)
&& !self.content.is_focused()
&& self.on_dismiss.is_some()
{
self.request_dismiss(ctx, -SPRING_KICK);
return EventResult::Handled;
}
let result = frust::authoring::route_event_single(&mut self.content, ctx, event);
self.observe_dismiss_request(ctx);
return result;
};
match p.phase {
PointerPhase::Down => {
ctx.request_focus();
if presses(p) && self.handle_target.contains(p.position) {
self.drag_captured = true;
self.drag_start_y = p.position.y;
self.drag_from = self.progress;
self.drag_moved = false;
self.tracker.clear();
self.tracker.record(self.event_time_ms(), p.position.y);
ctx.capture_pointer();
return EventResult::Handled;
}
if frust::authoring::route_event_single(&mut self.content, ctx, event)
== EventResult::Handled
{
self.observe_dismiss_request(ctx);
return EventResult::Handled;
}
if self.progress <= SETTLED_EPSILON {
return EventResult::Ignored;
}
if !presses(p) {
return EventResult::Handled;
}
self.scrim_captured = true;
self.scrim_down_outside = !self.panel.contains(p.position);
ctx.capture_pointer();
EventResult::Handled
}
_ => {
let result =
frust::authoring::route_event_single(&mut self.content, ctx, event);
self.observe_dismiss_request(ctx);
result
}
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::Dialog,
|node| node.set_modal(),
|ctx| {
self.content.semantics_child(ctx);
},
);
}
frust::authoring::visit_children!(content);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::NavigatorView;
use frust::TransitionSpec;
use frust::authoring::text::TextContext;
use frust::authoring::{
KeyEvent, Modifiers, NamedKey, PointerButton, PointerEvent, any as core_any,
};
use frust_core::RenderRoot;
use frust_widgets::navigator;
use frust_widgets::test_support::leaf_any;
use std::any::Any;
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn escape_event() -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
}
fn ft(ms: u64) -> FrameTime {
FrameTime::from_nanos(ms * 1_000_000)
}
fn build(view: &BottomSheetView<()>) -> BottomSheetWidget {
let mut counter = 0u64;
View::<()>::build(view, &mut BuildCtx::new(&mut counter))
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
paths: Vec<Color>,
path_boxes: Vec<Rect>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
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 fill_path(&mut self, origin: Point, path: &kurbo::BezPath, brush: &Brush) {
if let Brush::Solid(color) = brush {
self.paths.push(*color);
}
let b = path.bounding_box();
self.path_boxes.push(Rect::new(
b.x0 + origin.x,
b.y0 + origin.y,
b.x1 + origin.x,
b.y1 + origin.y,
));
}
}
impl Recorder {
fn panel_top(&self) -> Option<f64> {
self.path_boxes.first().map(|b| b.y0)
}
}
fn settle(w: &mut BottomSheetWidget, area: Size) {
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(area));
for i in 0..40u64 {
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(i * 20));
w.paint(&mut pctx, &mut Recorder::default());
if !w.anim.is_animating() {
return;
}
}
panic!("sheet spring failed to settle within the test bound");
}
fn tick(w: &mut BottomSheetWidget, area: Size, delta_ms: u64) {
let next_ms = w.last_frame_time.as_nanos() / 1_000_000 + delta_ms;
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(next_ms));
w.paint(&mut pctx, &mut Recorder::default());
}
#[test]
fn layout_starts_fully_offscreen_before_the_entrance_settles() {
let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let area = Size::new(400.0, 600.0);
let size = w.layout(&mut lctx, &BoxConstraints::tight(area));
assert_eq!(size, area, "the sheet fills the whole area (scrim)");
assert_eq!(w.panel.y0, area.height);
}
#[test]
fn layout_anchors_panel_to_bottom_with_handle_strip_once_settled() {
let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let area = Size::new(400.0, 600.0);
settle(&mut w, area);
assert_eq!(w.panel.x0, 0.0);
assert_eq!(w.panel.x1, area.width);
assert!(
(w.panel.y1 - area.height).abs() < 1e-6,
"panel bottom is the screen edge once at rest"
);
assert!((w.panel.height() - (HANDLE_TOUCH_TARGET + 200.0)).abs() < 1e-6);
assert_eq!(w.handle_target.y0, w.panel.y0);
assert_eq!(w.handle_target.height(), HANDLE_TOUCH_TARGET);
assert!((w.content.origin().y - (w.panel.y0 + HANDLE_TOUCH_TARGET)).abs() < 1e-6);
}
#[test]
fn unthemed_paint_draws_scrim_panel_and_handle_once_settled() {
let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let area = Size::new(400.0, 600.0);
settle(&mut w, area);
let mut rec = Recorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(1000));
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rects[0].1, area);
assert_eq!(rec.rects[0].2, scrim(None));
assert_eq!(rec.paths, vec![container(None, OverlayContainer::Low)]);
let handle = rec
.rrects
.iter()
.find(|(_, s, _, _)| *s == Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
.expect("the drag handle is painted");
assert_eq!(handle.3, on_surface_variant(None));
}
#[test]
fn themed_paint_resolves_tokens_once_settled() {
let theme = crate::baseline();
let scheme = theme.scheme();
let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let area = Size::new(400.0, 600.0);
settle(&mut w, area);
let mut rec = Recorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(1000)).with_theme(&theme);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rects[0].2, scrim(Some(&theme)));
assert_eq!(rec.paths, vec![scheme.surface_container_low]);
let handle = rec
.rrects
.iter()
.find(|(_, s, _, _)| *s == Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
.expect("the drag handle is painted");
assert_eq!(handle.3, scheme.on_surface_variant);
}
#[test]
fn the_entrance_progresses_smoothly_rather_than_jumping() {
let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let area = Size::new(400.0, 600.0);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(area));
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(0));
w.paint(&mut pctx, &mut Recorder::default());
assert_eq!(w.progress, 0.0, "the first frame is invisible");
assert!(w.anim.is_animating());
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(80));
w.paint(&mut pctx, &mut Recorder::default());
assert!(
w.progress > 0.0 && w.progress < 1.2,
"mid-flight progress {} should sit between the endpoints (allowing \
a modest expressive overshoot)",
w.progress
);
settle(&mut w, area);
assert!((w.progress - 1.0).abs() < 1e-6, "settles exactly at rest");
}
#[test]
fn a_dismiss_trigger_springs_the_panel_closed_over_several_frames() {
let view: BottomSheetView<Flag> =
bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
let area = Size::new(400.0, 600.0);
let mut counter = 0u64;
let mut w = View::<Flag>::build(&view, &mut BuildCtx::new(&mut counter));
settle(&mut w, area);
assert_eq!(w.progress, 1.0);
let mut state = Flag::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 1);
assert!(w.anim.is_animating());
assert_eq!(w.progress, 1.0, "progress hasn't moved yet — no paint ran");
let mut saw_partial_progress = false;
for i in 0..40u64 {
let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(i * 20));
w.paint(&mut pctx, &mut Recorder::default());
if w.progress > 1e-3 && w.progress < 1.0 - 1e-3 {
saw_partial_progress = true;
}
if !w.anim.is_animating() {
break;
}
}
assert!(
saw_partial_progress,
"the exit is a real ramp, not an instant snap to 0"
);
assert!((w.progress - 0.0).abs() < 1e-6);
}
#[derive(Default)]
struct Flag {
dismissed: u32,
}
fn build_flag(view: &BottomSheetView<Flag>) -> BottomSheetWidget {
let mut counter = 0u64;
View::<Flag>::build(view, &mut BuildCtx::new(&mut counter))
}
fn dispatch(w: &mut BottomSheetWidget, state: &mut Flag, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, event)
}
fn laid_out_flag_sheet() -> BottomSheetWidget {
let view: BottomSheetView<Flag> =
bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
let mut w = build_flag(&view);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
w
}
fn settled_flag_sheet() -> BottomSheetWidget {
let view: BottomSheetView<Flag> =
bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
let mut counter = 0u64;
let mut w = View::<Flag>::build(&view, &mut BuildCtx::new(&mut counter));
settle(&mut w, Size::new(400.0, 600.0));
w
}
fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
core_any::<Flag, _>(FixedLeaf {
size: Size::new(w, h),
})
}
struct FixedLeaf {
size: Size,
}
struct FixedLeafW {
size: Size,
}
impl View<Flag> for FixedLeaf {
type Element = FixedLeafW;
fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
FixedLeafW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for FixedLeafW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
}
#[test]
fn mid_drag_progress_tracks_the_pointer() {
let mut w = settled_flag_sheet();
let mut state = Flag::default();
let handle_y = w.panel.y0 + 10.0;
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
assert_eq!(w.progress, 1.0, "no movement yet");
let extent = w.panel.height();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 40.0),
);
let expected = (1.0 - 40.0 / extent).clamp(0.0, 1.0);
assert!(
(w.progress - expected).abs() < 1e-6,
"progress {} should track the 40px drag exactly ({} expected)",
w.progress,
expected
);
assert_eq!(state.dismissed, 0, "still mid-drag — nothing decided yet");
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 10.0),
);
let expected = (1.0 - 10.0 / extent).clamp(0.0, 1.0);
assert!((w.progress - expected).abs() < 1e-6);
}
#[test]
fn a_sub_slop_move_does_not_move_the_panel() {
let mut w = settled_flag_sheet();
let mut state = Flag::default();
let handle_y = w.panel.y0 + 10.0;
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 10.0), );
assert_eq!(w.progress, 1.0, "below the slop the panel doesn't move yet");
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 10.0),
);
assert_eq!(state.dismissed, 0, "a tap-shaped release does not dismiss");
}
#[test]
fn handle_drag_past_threshold_dismisses() {
let mut w = laid_out_flag_sheet();
let handle_y = w.panel.y0 + 10.0; let mut state = Flag::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
assert!(w.drag_captured);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 200.0),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 200.0),
);
assert_eq!(state.dismissed, 1, "a drag past half the sheet height pops");
}
#[test]
fn short_handle_drag_does_not_dismiss() {
let mut w = laid_out_flag_sheet();
let handle_y = w.panel.y0 + 10.0;
let mut state = Flag::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 20.0),
);
assert_eq!(state.dismissed, 0, "a short drag does not dismiss");
}
#[test]
fn a_drag_released_under_the_dismiss_threshold_springs_back_open() {
let mut w = settled_flag_sheet();
let mut state = Flag::default();
let handle_y = w.panel.y0 + 10.0;
let extent = w.panel.height();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 30.0),
);
assert!(w.progress < 1.0 && w.progress > DRAG_DISMISS_FRACTION);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 30.0),
);
assert_eq!(state.dismissed, 0, "under the threshold — stays open");
assert!(
w.anim.is_animating(),
"a real spring leg back toward 1.0 is running, not an instant snap"
);
let _ = extent;
}
#[test]
fn a_fast_downward_flick_dismisses_regardless_of_position() {
let mut w = settled_flag_sheet();
let mut state = Flag::default();
let handle_y = w.panel.y0 + 10.0;
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
tick(&mut w, Size::new(400.0, 600.0), 16);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 30.0),
);
assert!(w.progress > DRAG_DISMISS_FRACTION);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 30.0),
);
assert_eq!(state.dismissed, 1, "a fast downward flick dismisses");
}
#[test]
fn a_fast_upward_flick_stays_open_regardless_of_position() {
let mut w = settled_flag_sheet();
let mut state = Flag::default();
let handle_y = w.panel.y0 + 10.0;
let area = Size::new(400.0, 600.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 200.0),
);
assert!(w.progress < DRAG_DISMISS_FRACTION);
tick(&mut w, area, 150);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 150.0),
);
tick(&mut w, area, 16);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 100.0),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 100.0),
);
assert_eq!(state.dismissed, 0, "a fast upward flick stays open");
}
fn laid_out_non_dismissable_flag_sheet() -> BottomSheetWidget {
let view: BottomSheetView<Flag> = bottom_sheet(leaf_any_flag(300.0, 200.0))
.on_dismiss(|s: &mut Flag| s.dismissed += 1)
.dismissable(false);
let mut w = build_flag(&view);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
w
}
#[test]
fn dismissable_false_gates_handle_drag_dismiss() {
let mut w = laid_out_non_dismissable_flag_sheet();
let handle_y = w.panel.y0 + 10.0;
let mut state = Flag::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 200.0),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 200.0, handle_y + 200.0),
);
assert_eq!(
state.dismissed, 0,
"dismissable(false): a past-threshold drag settles back without firing"
);
assert!(
w.anim.is_animating(),
"it springs back open rather than staying stuck where the drag left it"
);
}
#[test]
fn dismissable_false_gates_scrim_tap() {
let mut w = laid_out_non_dismissable_flag_sheet();
let mut state = Flag::default();
assert!(!w.panel.contains(Point::new(5.0, 5.0)));
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 0, "dismissable(false) gates the scrim tap");
}
#[test]
fn scrim_tap_outside_panel_dismisses() {
let mut w = settled_flag_sheet();
let mut state = Flag::default();
assert!(!w.panel.contains(Point::new(5.0, 5.0)));
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 1);
}
#[derive(Default)]
struct NavState {
results: Vec<Option<i32>>,
}
#[test]
fn scrim_tap_pops_sheet_via_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
{
let dismiss = controller.clone();
controller.push_transparent_for_result(
move || {
let d = dismiss.clone();
core_any::<NavState, _>(
bottom_sheet(bg_page(400.0, 200.0))
.on_dismiss(move |_s: &mut NavState| d.pop()),
)
},
TransitionSpec::NONE,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
}
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, Size::new(400.0, 600.0));
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"scrim tap pops with an empty result"
);
}
fn settle_navigator_frame<State, V>(root: &mut RenderRoot<State, V>, area: Size)
where
V: frust::authoring::View<State>,
{
let mut tcx = TextContext::new();
for i in 0..40u64 {
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(i * 20));
}
}
#[test]
fn escape_after_a_short_handle_press_claims_focus_and_dismisses_via_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
{
let dismiss = controller.clone();
controller.push_transparent_for_result(
move || {
let d = dismiss.clone();
core_any::<NavState, _>(
bottom_sheet(bg_page(400.0, 200.0))
.on_dismiss(move |_s: &mut NavState| d.pop()),
)
},
TransitionSpec::NONE,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
}
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
let handle_y = area.height - 248.0 + 10.0;
root.event(&mut state, &ev(PointerPhase::Down, 200.0, handle_y));
root.event(&mut state, &ev(PointerPhase::Up, 200.0, handle_y + 10.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
assert!(
state.results.is_empty(),
"a short handle drag does not dismiss"
);
root.event(&mut state, &escape_event());
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"Escape dismisses the focused sheet"
);
}
#[test]
fn escape_without_a_prior_press_does_nothing() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
{
let dismiss = controller.clone();
controller.push_transparent_for_result(
move || {
let d = dismiss.clone();
core_any::<NavState, _>(
bottom_sheet(bg_page(400.0, 200.0))
.on_dismiss(move |_s: &mut NavState| d.pop()),
)
},
TransitionSpec::NONE,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
}
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &escape_event());
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert!(
state.results.is_empty(),
"Escape without prior focus is a no-op"
);
}
#[test]
fn a_down_reclaims_focus_after_an_external_blur() {
use frust::authoring::ChildPod;
let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
let area = Size::new(400.0, 600.0);
let w = build(&view);
let mut pod = ChildPod::new(Box::new(w));
let mut lctx = LayoutCtx::new();
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut dummy = ();
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(pod.is_focused(), "the first Down claims focus");
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Up, 5.0, 5.0));
}
pod.set_focused(false);
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(
pod.is_focused(),
"a second Down must re-claim focus after an external blur — this is what \
keeps the root's focus/IME session alive while the sheet is up"
);
}
#[derive(Default)]
struct FieldState {
value: String,
}
fn field_sheet_logic(state: &mut FieldState) -> BottomSheetView<FieldState> {
bottom_sheet(frust::SizedBox(None, Some(200.0)).child(frust::text_input(
state.value.clone(),
|s: &mut FieldState, v: String| s.value = v,
)))
}
#[test]
fn a_second_down_on_chrome_keeps_the_root_focus_session() {
let mut root: RenderRoot<FieldState, BottomSheetView<FieldState>> = RenderRoot::new();
let mut state = FieldState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut field_sheet_logic, &mut state);
settle_navigator_frame(&mut root, area);
root.event(&mut state, &ev(PointerPhase::Down, 200.0, 500.0));
root.event(&mut state, &ev(PointerPhase::Up, 200.0, 500.0));
assert!(root.is_focus_active(), "tap inside the field focuses it");
assert!(
root.ime_state().is_some(),
"focusing the field publishes an IME surface"
);
let handle_y = area.height - 248.0 + 10.0;
root.event(&mut state, &ev(PointerPhase::Down, 200.0, handle_y));
assert!(
root.is_focus_active(),
"a second Down on the sheet's own chrome must not blur the root's \
live focus session"
);
assert!(
root.ime_state().is_some(),
"the field's IME surface must survive a second Down on chrome that \
never itself routed to the field"
);
}
#[test]
fn dismissable_false_gates_escape_via_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
{
let dismiss = controller.clone();
controller.push_transparent_for_result(
move || {
let d = dismiss.clone();
core_any::<NavState, _>(
bottom_sheet(bg_page(400.0, 200.0))
.dismissable(false)
.on_dismiss(move |_s: &mut NavState| d.pop()),
)
},
TransitionSpec::NONE,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
}
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
let handle_y = area.height - 248.0 + 10.0;
root.event(&mut state, &ev(PointerPhase::Down, 200.0, handle_y));
root.event(&mut state, &ev(PointerPhase::Up, 200.0, handle_y + 10.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &escape_event());
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert!(
state.results.is_empty(),
"dismissable(false) gates Escape too"
);
}
#[test]
fn back_request_dismissable_true_dismisses_via_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_bottom_sheet(
&controller,
|| bottom_sheet(bg_page(400.0, 200.0)),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
assert!(
controller.back_interest(),
"a dismissable sheet claims back interest"
);
controller.request_back();
root.rebuild(&mut app, &mut state);
assert_eq!(
controller.depth(),
2,
"DismissAnimated: the stack is unchanged immediately"
);
let outcome = root.paint(&mut Recorder::default(), ft(900));
assert!(
outcome.needs_frame,
"the back-dismiss paint schedules the frames its staged exit needs"
);
assert_eq!(
controller.depth(),
2,
"the exit ramp is only started at paint — the pop comes later"
);
let mut frames = 0u64;
loop {
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let outcome = root.paint(&mut Recorder::default(), ft(1000 + frames * 100));
frames += 1;
assert!(
frames < 20,
"the exit ramp settles and the pop drains within a bounded number of frames"
);
if !outcome.needs_frame {
break;
}
}
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"a back request dismisses the sheet"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn a_scrim_tap_animates_the_panel_out_before_the_navigator_pop_lands() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_bottom_sheet(
&controller,
|| bottom_sheet(bg_page(400.0, 200.0)),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
let rest_top = area.height - 248.0;
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(
controller.depth(),
2,
"the tap stages the exit — it does not pop on the spot"
);
let mut tops: Vec<f64> = Vec::new();
let mut frames = 0u64;
while controller.depth() == 2 {
frames += 1;
assert!(
frames < 60,
"the staged exit settles and pops within a bounded number of frames"
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let mut rec = Recorder::default();
root.paint(&mut rec, ft(1000 + frames * 20));
if let Some(top) = rec.panel_top() {
tops.push(top);
}
}
let mid: Vec<f64> = tops
.iter()
.copied()
.filter(|t| *t > rest_top + 1.0 && *t < area.height - 1.0)
.collect();
assert!(
mid.len() >= 3,
"the exit is a visible ramp, not a snap: intermediate panel tops {mid:?} \
(all frames: {tops:?})"
);
assert!(
mid.windows(2).all(|w| w[1] > w[0]),
"the panel only ever travels downward on the way out: {mid:?}"
);
assert!(
tops.last().is_some_and(|t| (t - area.height).abs() < 1.0),
"the panel is fully off-screen on the frame the pop finally fires: {tops:?}"
);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"and the sheet really did pop, with an empty result"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn reduce_motion_collapses_the_staged_exit_and_still_pops() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
root.set_theme(Box::new(theme));
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_bottom_sheet(
&controller,
|| bottom_sheet(bg_page(400.0, 200.0)),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
for i in 0..3u64 {
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1000 + i * 20));
}
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"reduce_motion still dismisses — it only skips the ramp"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn a_page_pushed_during_the_exit_ramp_survives_and_the_sheet_stays_dismissable() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_bottom_sheet(
&controller,
|| bottom_sheet(bg_page(400.0, 200.0)),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(controller.depth(), 2, "the tap only stages");
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1000));
controller.push_transparent(|| core_any::<NavState, _>(bg_page(400.0, 100.0)));
for i in 0..40u64 {
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1020 + i * 20));
}
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
controller.depth(),
3,
"the pushed page survives — the staged pop was refused, not aimed at it"
);
assert!(
state.results.is_empty(),
"and the sheet reported no dismissal it never made"
);
controller.request_back();
for i in 0..40u64 {
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(2000 + i * 20));
}
assert_eq!(controller.depth(), 2, "the pushed page backs out normally");
controller.request_back();
for i in 0..40u64 {
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(3000 + i * 20));
}
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"the sheet is still dismissible after the refused pop"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn a_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_bottom_sheet(
&controller,
move || bottom_sheet(bg_page(400.0, 200.0)).dismiss_handle(installed.clone()),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
dismiss.dismiss_with(PopResult::of(3));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1000));
assert_eq!(
controller.depth(),
2,
"the request stages the exit — it does not pop on the spot"
);
let mut frames = 0u64;
while controller.depth() == 2 {
frames += 1;
assert!(
frames < 60,
"the staged exit settles and pops within a bounded number of frames"
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1000 + frames * 20));
}
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![Some(3)],
"the request's own payload rides the staged pop"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn repeated_dismiss_requests_are_idempotent() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_bottom_sheet(
&controller,
move || bottom_sheet(bg_page(400.0, 200.0)).dismiss_handle(installed.clone()),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
dismiss.dismiss();
dismiss.dismiss();
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1000));
assert_eq!(controller.depth(), 2, "still staging, not popped yet");
dismiss.dismiss();
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1020));
let mut frames = 0u64;
while controller.depth() == 2 {
frames += 1;
assert!(frames < 60, "the exit still settles within a bounded time");
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(1050 + frames * 20));
}
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"exactly one pop, however many requests were raised"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn a_stack_mounted_sheets_handle_reaches_the_unstaged_on_dismiss() {
let dismiss = ModalDismiss::new();
let view: BottomSheetView<Flag> = bottom_sheet(leaf_any_flag(300.0, 200.0))
.on_dismiss(|s: &mut Flag| s.dismissed += 1)
.dismiss_handle(dismiss.clone());
let mut w = build_flag(&view);
let area = Size::new(400.0, 600.0);
settle(&mut w, area);
dismiss.dismiss();
tick(&mut w, area, 20);
let mut state = Flag::default();
assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.dismissed, 1,
"a `Stack` mount's handle is not inert — it dismisses unstaged"
);
tick(&mut w, area, 20);
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 6.0));
assert_eq!(state.dismissed, 1);
}
#[test]
fn a_scrim_tap_then_move_with_a_pending_request_dismisses_exactly_once() {
let dismiss = ModalDismiss::new();
let view: BottomSheetView<Flag> = bottom_sheet(leaf_any_flag(300.0, 200.0))
.on_dismiss(|s: &mut Flag| s.dismissed += 1)
.dismiss_handle(dismiss.clone());
let mut w = build_flag(&view);
let area = Size::new(400.0, 600.0);
settle(&mut w, area);
dismiss.dismiss();
tick(&mut w, area, 20);
let mut state = Flag::default();
assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
assert!(!w.panel.contains(Point::new(5.0, 5.0)));
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 1, "the tap dismissed the sheet");
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 6.0));
assert_eq!(
state.dismissed, 1,
"the pending request rides the dismissal that already happened"
);
}
struct SheetRig {
controller: NavigatorController<NavState>,
root: RenderRoot<NavState, NavigatorView<NavState>>,
state: NavState,
tcx: TextContext,
ms: u64,
}
impl SheetRig {
fn presented() -> (Self, ModalDismiss) {
let mut rig = SheetRig {
controller: NavigatorController::new(),
root: RenderRoot::new(),
state: NavState::default(),
tcx: TextContext::new(),
ms: 0,
};
rig.frame();
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_bottom_sheet(
&rig.controller,
move || bottom_sheet(bg_page(400.0, 200.0)).dismiss_handle(installed.clone()),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
rig.drive(40);
(rig, dismiss)
}
fn app(&self) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> + use<> {
let ctrl = self.controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
}
fn frame(&mut self) {
let area = Size::new(400.0, 600.0);
let mut app = self.app();
self.root.rebuild(&mut app, &mut self.state);
self.root
.layout_with_text(area, &mut self.tcx as &mut dyn Any);
self.ms += 20;
self.root.paint(&mut Recorder::default(), ft(self.ms));
}
fn drive(&mut self, frames: u64) {
for _ in 0..frames {
self.frame();
}
}
fn flush(&mut self) {
self.root
.event(&mut self.state, &ev(PointerPhase::Move, 5.0, 5.0));
}
}
#[test]
fn a_request_refused_mid_exit_never_retargets_the_exit_it_found() {
let (mut rig, dismiss) = SheetRig::presented();
dismiss.dismiss();
rig.frame();
dismiss.dismiss_with(PopResult::of(7));
rig.drive(60);
rig.flush();
assert_eq!(
rig.state.results,
vec![None],
"the exit in flight keeps its own empty result"
);
assert_eq!(rig.controller.depth(), 1);
}
#[test]
fn a_refused_staged_pop_drops_the_payload_it_was_carrying() {
let (mut rig, dismiss) = SheetRig::presented();
dismiss.dismiss_with(PopResult::of(7));
rig.frame();
rig.controller
.push_transparent(|| core_any::<NavState, _>(bg_page(400.0, 100.0)));
rig.drive(60);
rig.flush();
assert_eq!(rig.controller.depth(), 3, "the pushed page survives");
assert!(rig.state.results.is_empty(), "nothing was delivered");
rig.controller.request_back();
rig.drive(60);
assert_eq!(rig.controller.depth(), 2);
rig.controller.request_back();
rig.drive(60);
rig.flush();
assert_eq!(
rig.state.results,
vec![None],
"a later, unrelated dismissal pops with its own empty result"
);
assert_eq!(rig.controller.depth(), 1);
}
#[test]
fn a_plain_dismiss_after_an_unobserved_dismiss_with_pops_empty() {
let (mut rig, dismiss) = SheetRig::presented();
dismiss.dismiss_with(PopResult::of(7));
dismiss.dismiss();
rig.drive(60);
rig.flush();
assert_eq!(
rig.state.results,
vec![None],
"the plain dismissal pops with nothing, not with the stale payload"
);
assert_eq!(rig.controller.depth(), 1);
}
fn staged_flag_sheet() -> (BottomSheetWidget, Rc<Cell<u64>>, Rc<Cell<u32>>) {
let signal = Rc::new(Cell::new(0u64));
let pops = Rc::new(Cell::new(0u32));
let counter = pops.clone();
let mut view: BottomSheetView<Flag> =
bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
view.on_close = Some(Rc::new(StagedPop::unguarded(move || {
counter.set(counter.get() + 1);
})));
view.dismiss_signal = Some(signal.clone());
let mut w = build_flag(&view);
settle(&mut w, Size::new(400.0, 600.0));
(w, signal, pops)
}
fn settle_from_here(w: &mut BottomSheetWidget, area: Size) {
for _ in 0..60u64 {
tick(w, area, 20);
if !w.anim.is_animating() {
return;
}
}
panic!("sheet spring failed to settle within the test bound");
}
#[test]
fn a_release_back_open_cancels_an_exit_staged_under_the_drag() {
for cancelled in [false, true] {
let (mut w, signal, pops) = staged_flag_sheet();
let area = Size::new(400.0, 600.0);
let mut state = Flag::default();
let handle_y = w.panel.y0 + 10.0;
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 200.0, handle_y + 30.0),
);
assert!(w.progress > DRAG_DISMISS_FRACTION);
signal.set(signal.get() + 1);
tick(&mut w, area, 16);
assert!(w.exiting, "the back press stages an exit under the drag");
let phase = if cancelled {
PointerPhase::Cancel
} else {
PointerPhase::Up
};
dispatch(&mut w, &mut state, &ev(phase, 200.0, handle_y + 30.0));
assert!(
!w.exiting,
"a leg aimed back open cancels the staged exit (cancelled: {cancelled})"
);
settle_from_here(&mut w, area);
assert!(
(w.progress - 1.0).abs() < 1e-6,
"the sheet is back at rest, open: {}",
w.progress
);
assert_eq!(pops.get(), 0, "nothing popped — the sheet reopened");
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
settle_from_here(&mut w, area);
assert_eq!(
pops.get(),
1,
"a scrim tap after the reopen still dismisses (cancelled: {cancelled})"
);
}
}
#[test]
fn a_sheet_that_has_finished_its_exit_swallows_no_press() {
let (mut w, _signal, pops) = staged_flag_sheet();
let area = Size::new(400.0, 600.0);
let mut state = Flag::default();
let over_panel = Point::new(200.0, w.panel.y0 + 100.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
settle_from_here(&mut w, area);
assert_eq!(pops.get(), 1, "the staged pop fired");
assert!(w.closed, "and latched — the sheet is on its way out");
assert!(w.progress <= SETTLED_EPSILON, "painting nothing");
let result = dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, over_panel.x, over_panel.y),
);
assert_eq!(
result,
EventResult::Ignored,
"an invisible sheet absorbs nothing"
);
assert!(!w.scrim_captured, "and arms no scrim dismiss either");
}
#[test]
fn the_entrance_animates_only_the_panel_never_the_page() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_bottom_sheet(
&controller,
|| bottom_sheet(bg_page(400.0, 200.0)),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
let mut tops: Vec<f64> = Vec::new();
for i in 0..40u64 {
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let mut rec = Recorder::default();
root.paint(&mut rec, ft(i * 20));
for (origin, size, _) in &rec.rects {
if *size == area {
assert_eq!(
*origin,
Point::ZERO,
"a full-area fill painted away from the window origin means the \
navigator is sliding the page too (frame {i})"
);
}
}
if let Some(top) = rec.panel_top() {
tops.push(top);
}
}
let rest_top = area.height - 248.0;
assert!(
tops.first().is_some_and(|t| (t - area.height).abs() < 1e-6),
"the first frame is invisible — the panel starts fully below the fold: {tops:?}"
);
assert!(
tops.iter()
.any(|t| *t > rest_top + 1.0 && *t < area.height - 1.0),
"the panel climbs through intermediate positions: {tops:?}"
);
assert!(
tops.last().is_some_and(|t| (t - rest_top).abs() < 1e-6),
"…and comes to rest with its top edge at {rest_top}: {tops:?}"
);
}
#[test]
fn back_request_dismissable_false_vetoes_stack_unchanged() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
}
};
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_bottom_sheet(
&controller,
|| bottom_sheet(bg_page(400.0, 200.0)).dismissable(false),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
settle_navigator_frame(&mut root, area);
assert!(
controller.back_interest(),
"a Veto (non-dismissable) sheet still claims back interest"
);
controller.request_back();
root.rebuild(&mut app, &mut state);
assert_eq!(controller.depth(), 2, "Veto leaves the stack unchanged");
root.paint(&mut Recorder::default(), ft(950));
root.paint(&mut Recorder::default(), ft(1400));
assert!(
state.results.is_empty(),
"a non-dismissable sheet never pops on a back request"
);
assert_eq!(controller.depth(), 2);
}
struct BgPage {
size: Size,
}
struct BgPageW {
size: Size,
}
impl View<NavState> for BgPage {
type Element = BgPageW;
fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
BgPageW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for BgPageW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
fn bg_page(w: f64, h: f64) -> BgPage {
BgPage {
size: Size::new(w, h),
}
}
#[test]
fn semantics_is_a_modal_dialog_ish_node() {
fn logic(_s: &mut ()) -> BottomSheetView<()> {
bottom_sheet(leaf_any(300.0, 200.0))
}
let mut root: RenderRoot<(), BottomSheetView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(node.is_modal(), "the sheet node sets the modal flag");
}
}