use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{
Affine, AnyView, BezPath, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color,
CornerRadii, CursorIcon, ErasedCallback, EventCtx, EventResult, InputEvent, Key, LayoutCtx,
NamedKey, PaintCtx, PaintScene, Point, PointerPhase, Rect, Role, SemanticsCtx, Size, View,
Widget, any, build_child, erase_callback, rebuild_child, route_event_single, teardown_child,
visit_children,
};
use frust::input::{TOUCH_SLOP, VelocityTracker};
use frust::{
AnimationController, BackPolicy, Curve, FrameTime, IconData, NavigatorController, PopResult,
PushOptions, Theme, TransitionSpec,
};
use super::{OverlayContainer, OverlayElevation, OverlaySide, finite_or_zero, reduce_motion};
use crate::press::presses;
use crate::tokens::{MaterialMotion, MaterialSpacing};
pub const OVERLAY_DIALOG_MIN_WIDTH: f64 = 280.0;
pub const OVERLAY_DIALOG_MAX_WIDTH: f64 = 560.0;
const CENTERED_MARGIN: f64 = MaterialSpacing::LG;
pub const OVERLAY_EDGE_FRACTION: f64 = 0.75;
pub const OVERLAY_SIDE_SHEET_MAX_WIDTH: f64 = 400.0;
pub const OVERLAY_SHEET_MAX_HEIGHT_FRACTION: f64 = 0.8;
pub const SCALE_FROM: f64 = 0.95;
const PROGRESS_EPSILON: f64 = 1e-4;
pub const OVERLAY_FLING_VELOCITY: f64 = 400.0;
const CLOSE_INSET: f64 = MaterialSpacing::LG;
const CLOSE_ICON: f64 = 24.0;
const CLOSE_HIT: f64 = 48.0;
const HANDLE_WIDTH: f64 = 32.0;
const HANDLE_HEIGHT: f64 = 4.0;
const HANDLE_TOUCH_TARGET: f64 = 48.0;
pub const OVERLAY_HANDLE_RESERVE: f64 = HANDLE_TOUCH_TARGET;
const SHADOW_SPILL: f64 = 48.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OverlayExtent {
Fraction(f64),
Content,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OverlayLimit {
None,
Px(f64),
Fraction(f64),
}
impl OverlayLimit {
fn apply(self, value: f64, area: f64) -> f64 {
let cap = match self {
OverlayLimit::None => area,
OverlayLimit::Px(px) => px.min(area),
OverlayLimit::Fraction(f) => area * f,
};
value.min(cap)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OverlayGeometry {
Centered {
max_width: f64,
},
Edge {
side: OverlaySide,
extent: OverlayExtent,
limit: OverlayLimit,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlayCorners {
#[default]
All,
None,
Top,
Bottom,
Start,
End,
}
impl OverlayCorners {
fn radii(self, radius: f64) -> CornerRadii {
let (tl, tr, br, bl) = match self {
OverlayCorners::All => (radius, radius, radius, radius),
OverlayCorners::None => (0.0, 0.0, 0.0, 0.0),
OverlayCorners::Top => (radius, radius, 0.0, 0.0),
OverlayCorners::Bottom => (0.0, 0.0, radius, radius),
OverlayCorners::Start => (radius, 0.0, 0.0, radius),
OverlayCorners::End => (0.0, radius, radius, 0.0),
};
CornerRadii::new(tl, tr, br, bl)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlayBorder {
#[default]
None,
All,
Edge(OverlaySide),
}
const BORDER_WIDTH: f64 = 1.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlayEntrance {
#[default]
FadeScale,
Slide,
None,
}
impl OverlayEntrance {
fn duration(self) -> Duration {
match self {
OverlayEntrance::FadeScale => MaterialMotion::SHORT_4,
OverlayEntrance::Slide => MaterialMotion::LONG_2,
OverlayEntrance::None => Duration::ZERO,
}
}
fn curve(self, exiting: bool) -> Curve {
if exiting {
return MaterialMotion::EMPHASIZED_ACCELERATE;
}
match self {
OverlayEntrance::Slide => MaterialMotion::EMPHASIZED,
_ => MaterialMotion::EMPHASIZED_DECELERATE,
}
}
fn is_layout_affecting(self) -> bool {
matches!(self, OverlayEntrance::Slide)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlayRole {
#[default]
Dialog,
AlertDialog,
}
impl OverlayRole {
fn role(self) -> Role {
match self {
OverlayRole::Dialog => Role::Dialog,
OverlayRole::AlertDialog => Role::AlertDialog,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OverlayModalConfig {
pub geometry: OverlayGeometry,
pub corners: OverlayCorners,
pub border: OverlayBorder,
pub container: OverlayContainer,
pub elevation: OverlayElevation,
pub entrance: OverlayEntrance,
pub scrim_dismiss: bool,
pub close_button: bool,
pub handle: bool,
pub drag: bool,
pub role: OverlayRole,
}
impl OverlayModalConfig {
pub fn centered(max_width: f64) -> Self {
OverlayModalConfig {
geometry: OverlayGeometry::Centered { max_width },
corners: OverlayCorners::All,
border: OverlayBorder::None,
container: OverlayContainer::High,
elevation: OverlayElevation::Level3,
entrance: OverlayEntrance::FadeScale,
scrim_dismiss: true,
close_button: false,
handle: false,
drag: false,
role: OverlayRole::Dialog,
}
}
pub fn edge(side: OverlaySide) -> Self {
let (extent, limit) = if side.is_vertical() {
(OverlayExtent::Content, OverlayLimit::None)
} else {
(
OverlayExtent::Fraction(OVERLAY_EDGE_FRACTION),
OverlayLimit::Px(OVERLAY_SIDE_SHEET_MAX_WIDTH),
)
};
let corners = match side {
OverlaySide::Bottom => OverlayCorners::Top,
OverlaySide::Top => OverlayCorners::Bottom,
OverlaySide::Left => OverlayCorners::End,
OverlaySide::Right => OverlayCorners::Start,
};
OverlayModalConfig {
geometry: OverlayGeometry::Edge {
side,
extent,
limit,
},
corners,
border: OverlayBorder::None,
container: OverlayContainer::Low,
elevation: OverlayElevation::Level1,
entrance: OverlayEntrance::Slide,
scrim_dismiss: true,
close_button: false,
handle: false,
drag: false,
role: OverlayRole::Dialog,
}
}
pub fn extent(mut self, extent: OverlayExtent, limit: OverlayLimit) -> Self {
if let OverlayGeometry::Edge {
extent: e,
limit: l,
..
} = &mut self.geometry
{
*e = extent;
*l = limit;
}
self
}
pub fn corners(mut self, corners: OverlayCorners) -> Self {
self.corners = corners;
self
}
pub fn border(mut self, border: OverlayBorder) -> Self {
self.border = border;
self
}
pub fn container(mut self, container: OverlayContainer) -> Self {
self.container = container;
self
}
pub fn elevation(mut self, elevation: OverlayElevation) -> Self {
self.elevation = elevation;
self
}
pub fn entrance(mut self, entrance: OverlayEntrance) -> Self {
self.entrance = entrance;
self
}
pub fn scrim_dismiss(mut self, scrim_dismiss: bool) -> Self {
self.scrim_dismiss = scrim_dismiss;
self
}
pub fn close_button(mut self, close_button: bool) -> Self {
self.close_button = close_button;
self
}
pub fn handle(mut self, handle: bool) -> Self {
self.handle = handle;
self
}
pub fn drag(mut self, drag: bool) -> Self {
self.drag = drag;
self
}
pub fn role(mut self, role: OverlayRole) -> Self {
self.role = role;
self
}
}
type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
pub type OnClose = Rc<StagedPop>;
pub struct StagedPop {
depth: Option<Box<dyn Fn() -> usize>>,
close: Box<dyn Fn(PopResult)>,
staged_depth: Cell<Option<usize>>,
}
impl StagedPop {
pub(crate) fn navigator_pop<State: 'static>(controller: &NavigatorController<State>) -> Self {
let depth_ctrl = controller.clone();
let pop_ctrl = controller.clone();
Self {
depth: Some(Box::new(move || depth_ctrl.depth())),
close: Box::new(move |result| pop_ctrl.pop_with_result(result)),
staged_depth: Cell::new(None),
}
}
pub(crate) fn unguarded<F: Fn() + 'static>(close: F) -> Self {
Self {
depth: None,
close: Box::new(move |_result| close()),
staged_depth: Cell::new(None),
}
}
pub(crate) fn arm(&self) {
self.staged_depth
.set(self.depth.as_ref().map(|depth| depth()));
}
#[must_use]
pub(crate) fn fire_with(&self, result: &mut Option<PopResult>) -> bool {
if let (Some(depth), Some(staged)) = (&self.depth, self.staged_depth.get())
&& depth() != staged
{
return false;
}
(self.close)(result.take().unwrap_or_else(PopResult::empty));
true
}
}
#[derive(Clone, Default)]
pub struct ModalDismiss(Rc<DismissRequest>);
#[derive(Default)]
struct DismissRequest {
generation: Cell<u64>,
result: Cell<Option<(u64, PopResult)>>,
}
impl ModalDismiss {
pub fn new() -> Self {
Self::default()
}
pub fn dismiss(&self) {
self.request(None);
}
pub fn dismiss_with(&self, result: PopResult) {
self.request(Some(result));
}
fn request(&self, result: Option<PopResult>) {
let next = self.0.generation.get().wrapping_add(1);
let superseded = self.0.result.replace(result.map(|result| (next, result)));
drop(superseded);
self.0.generation.set(next);
}
pub(crate) fn generation(&self) -> u64 {
self.0.generation.get()
}
pub(crate) fn take_result(&self, generation: u64) -> Option<PopResult> {
match self.0.result.take() {
Some((stamp, result)) if stamp == generation => Some(result),
other => {
self.0.result.set(other);
None
}
}
}
pub(crate) fn same_cell(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.0, &other.0)
}
}
pub trait OverlayModalContent<State: 'static>:
View<State, Element = OverlayModalWidget> + Sized + 'static
{
fn on_modal_dismiss(self, on_dismiss: OnDismiss<State>) -> Self;
fn modal_dismissable(&self) -> bool;
}
pub fn overlay_modal<State: 'static, V: View<State>>(
content: V,
config: OverlayModalConfig,
) -> OverlayModalView<State> {
OverlayModalView {
content: any(content),
config,
label: None,
dismissable: true,
on_dismiss: None,
on_close: None,
dismiss_handle: None,
snap_points: Vec::new(),
}
}
pub struct OverlayModalView<State: 'static> {
pub(crate) content: AnyView<State>,
pub(crate) config: OverlayModalConfig,
pub(crate) label: Option<String>,
pub(crate) dismissable: bool,
pub(crate) on_dismiss: Option<OnDismiss<State>>,
pub(crate) on_close: Option<OnClose>,
pub(crate) dismiss_handle: Option<ModalDismiss>,
pub(crate) snap_points: Vec<f64>,
}
impl<State: 'static> OverlayModalView<State> {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
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 on_close<F: Fn() + 'static>(mut self, on_close: F) -> Self {
self.on_close = Some(Rc::new(StagedPop::unguarded(on_close)));
self
}
pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
self.dismiss_handle = Some(dismiss);
self
}
pub fn snap_points(mut self, points: &[f64]) -> Self {
self.snap_points = points.iter().copied().filter(|p| p.is_finite()).collect();
self
}
}
impl<State: 'static> OverlayModalContent<State> for OverlayModalView<State> {
fn on_modal_dismiss(mut self, on_dismiss: OnDismiss<State>) -> Self {
self.on_dismiss = Some(on_dismiss);
self
}
fn modal_dismissable(&self) -> bool {
self.dismissable
}
}
pub fn show_overlay_modal<State, V, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) where
State: 'static,
V: OverlayModalContent<State>,
B: Fn() -> V + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let dismiss_ctrl = controller.clone();
let staged: OnClose = Rc::new(StagedPop::navigator_pop(controller));
let dismissable = build().modal_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();
any::<State, _>(StagedExit {
inner: build().on_modal_dismiss(Rc::new(move |_state: &mut State| ctrl.pop())),
on_close: staged.clone(),
dismiss_signal: widget_signal.clone(),
})
},
options,
);
}
struct StagedExit<V> {
inner: V,
on_close: OnClose,
dismiss_signal: Option<Rc<Cell<u64>>>,
}
impl<State: 'static, V: View<State, Element = OverlayModalWidget>> View<State> for StagedExit<V> {
type Element = OverlayModalWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
let mut widget = self.inner.build(ctx);
widget.on_close = Some(self.on_close.clone());
widget.last_seen_dismiss = self.dismiss_signal.as_ref().map_or(0, |s| s.get());
widget.dismiss_signal = self.dismiss_signal.clone();
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut OverlayModalWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let flags = self.inner.rebuild(&prev.inner, element, ctx);
element.on_close = Some(self.on_close.clone());
element.dismiss_signal = self.dismiss_signal.clone();
flags
}
fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
self.inner.teardown(element, ctx);
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DismissSource {
User,
App,
}
enum DismissStage {
Refused,
Ramping,
Unstaged,
}
pub struct OverlayModalWidget {
content: ChildPod,
config: OverlayModalConfig,
label: Option<String>,
dismissable: bool,
on_dismiss: Option<ErasedCallback>,
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>,
snap_points: Vec<f64>,
panel: Rect,
anim: AnimationController,
ramp: (f64, f64),
progress: f64,
started: bool,
exiting: bool,
closed: bool,
dismissed: bool,
scrim_captured: bool,
scrim_down_outside: bool,
close_hovered: bool,
close_captured: bool,
handle_hovered: bool,
handle_captured: bool,
drag_captured: bool,
drag_start: f64,
drag_from: f64,
drag_on_handle: bool,
drag_moved: bool,
tracker: VelocityTracker,
last_frame_time: FrameTime,
}
impl OverlayModalWidget {
pub fn panel_rect(&self) -> Rect {
self.panel
}
pub fn close_rect(&self) -> Option<Rect> {
self.config.close_button.then(|| {
let center = Point::new(
self.panel.x1 - CLOSE_INSET - CLOSE_ICON / 2.0,
self.panel.y0 + CLOSE_INSET + CLOSE_ICON / 2.0,
);
Rect::from_center_size(center, Size::new(CLOSE_HIT, CLOSE_HIT))
})
}
pub fn handle_rect(&self) -> Option<Rect> {
self.config
.handle
.then(|| Rect::from_center_size(self.handle_center(), Size::new(CLOSE_HIT, CLOSE_HIT)))
}
fn handle_center(&self) -> Point {
Point::new(
self.panel.center().x,
self.panel.y0 + HANDLE_TOUCH_TARGET / 2.0,
)
}
fn handle_bar(&self) -> Rect {
Rect::from_center_size(self.handle_center(), Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
}
pub fn progress(&self) -> f64 {
self.progress
}
pub fn is_exiting(&self) -> bool {
self.exiting
}
pub fn open_progress(&self) -> f64 {
self.snap_points
.first()
.map(|p| self.normalize_snap(*p))
.filter(|p| *p > PROGRESS_EPSILON)
.unwrap_or(1.0)
}
fn normalize_snap(&self, point: f64) -> f64 {
if point <= 1.0 {
return point.clamp(0.0, 1.0);
}
let extent = self.extent();
if extent <= 0.0 {
return 1.0;
}
(point / extent).clamp(0.0, 1.0)
}
fn snap_targets(&self) -> Vec<f64> {
let mut targets: Vec<f64> = std::iter::once(0.0)
.chain(self.snap_points.iter().map(|p| self.normalize_snap(*p)))
.collect();
if targets.len() == 1 {
targets.push(1.0);
}
targets.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
targets
}
fn extent(&self) -> f64 {
match self.config.geometry {
OverlayGeometry::Edge { side, .. } if side.is_vertical() => self.panel.height(),
OverlayGeometry::Edge { .. } => self.panel.width(),
OverlayGeometry::Centered { .. } => 0.0,
}
}
fn close_sign(&self) -> f64 {
match self.config.geometry {
OverlayGeometry::Edge {
side: OverlaySide::Bottom | OverlaySide::Right,
..
} => 1.0,
_ => -1.0,
}
}
fn axis_pos(&self, position: Point) -> f64 {
match self.config.geometry {
OverlayGeometry::Edge { side, .. } if side.is_vertical() => position.y,
_ => position.x,
}
}
fn event_time_ms(&self) -> f64 {
self.last_frame_time.as_secs_f64() * 1000.0
}
fn can_dismiss(&self) -> bool {
self.dismissable && (self.on_dismiss.is_some() || self.on_close.is_some())
}
fn begin_ramp(&mut self, to: f64, exiting: bool) {
let from = self.progress;
self.ramp = (from, to);
self.exiting = exiting;
if exiting {
if let Some(on_close) = &self.on_close {
on_close.arm();
}
} else {
self.pending_result = None;
}
let fraction = (to - from).abs().clamp(0.0, 1.0);
let duration = self.config.entrance.duration().mul_f64(fraction);
self.anim =
AnimationController::new(duration).with_curve(self.config.entrance.curve(exiting));
self.anim.forward();
self.started = true;
}
fn ramp_value(&self) -> f64 {
let (from, to) = self.ramp;
from + (to - from) * self.anim.value_clamped()
}
fn request_continuation(&self, ctx: &mut PaintCtx) {
if self.config.entrance.is_layout_affecting() {
ctx.request_layout();
} else {
ctx.request_frame();
}
}
fn stageable(&self) -> bool {
self.on_close.is_some() && self.config.entrance != OverlayEntrance::None
}
fn stage_dismiss(&mut self, source: DismissSource) -> DismissStage {
if self.exiting || self.closed || self.dismissed {
return DismissStage::Refused;
}
if source == DismissSource::User && !self.dismissable {
return DismissStage::Refused;
}
if !self.stageable() {
return DismissStage::Unstaged;
}
self.begin_ramp(0.0, true);
DismissStage::Ramping
}
fn fire_close_now(&mut self) -> bool {
let Some(on_close) = self.on_close.clone() else {
return false;
};
let fired = on_close.fire_with(&mut self.pending_result);
self.closed = fired;
fired
}
fn fire_dismiss_now(&mut self, ctx: &mut EventCtx) -> bool {
let Some(on_dismiss) = self.on_dismiss.as_mut() else {
return false;
};
on_dismiss(ctx);
self.dismissed = true;
true
}
fn request_dismiss(&mut self, ctx: &mut EventCtx) {
match self.stage_dismiss(DismissSource::User) {
DismissStage::Refused => {}
DismissStage::Ramping => ctx.request_redraw(),
DismissStage::Unstaged => {
if !self.fire_dismiss_now(ctx) {
self.fire_close_now();
}
}
}
}
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 observe_dismiss_request(&mut self, ctx: &mut EventCtx) {
let Some(request) = self.pending_dismiss_request() else {
return;
};
match self.stage_dismiss(DismissSource::App) {
DismissStage::Refused => self.latch_dismiss_request(request),
DismissStage::Ramping => {
self.accept_dismiss_request(request);
ctx.request_redraw();
}
DismissStage::Unstaged => {
if self.on_close.is_some() {
self.accept_dismiss_request(request);
self.fire_close_now();
} else {
self.latch_dismiss_request(request);
self.fire_dismiss_now(ctx);
}
}
}
}
fn observe_paint_dismiss_request(&mut self, ctx: &mut PaintCtx) {
let Some(request) = self.pending_dismiss_request() else {
return;
};
match self.stage_dismiss(DismissSource::App) {
DismissStage::Refused => self.latch_dismiss_request(request),
DismissStage::Ramping => {
self.accept_dismiss_request(request);
self.request_continuation(ctx);
}
DismissStage::Unstaged => {
if self.on_close.is_some() {
self.accept_dismiss_request(request);
if self.fire_close_now() {
ctx.request_frame();
}
}
}
}
}
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;
match self.stage_dismiss(DismissSource::User) {
DismissStage::Refused => {}
DismissStage::Ramping => self.request_continuation(ctx),
DismissStage::Unstaged => {
if self.fire_close_now() {
ctx.request_frame();
}
}
}
}
fn scrub(&mut self, ctx: &mut EventCtx, progress: f64) {
if (progress - self.progress).abs() < PROGRESS_EPSILON {
return;
}
self.anim.stop();
self.exiting = false;
self.pending_result = None;
self.progress = progress;
self.ramp = (progress, progress);
self.reposition(ctx.size());
ctx.request_redraw();
}
fn reposition(&mut self, area: Size) {
let OverlayGeometry::Edge { side, .. } = self.config.geometry else {
return;
};
let size = self.panel.size();
let origin = edge_origin(side, area, size, self.progress);
self.panel = Rect::from_origin_size(origin, size);
self.content.set_origin(origin);
}
fn begin_drag(&mut self, ctx: &mut EventCtx, position: Point, on_handle: bool) {
self.drag_captured = true;
self.drag_on_handle = on_handle;
self.drag_moved = false;
self.drag_start = self.axis_pos(position);
self.drag_from = self.progress;
self.tracker.clear();
self.tracker.record(self.event_time_ms(), self.drag_start);
ctx.capture_pointer();
}
fn drag_move(&mut self, ctx: &mut EventCtx, position: Point) {
let axis = self.axis_pos(position);
self.tracker.record(self.event_time_ms(), axis);
let delta = axis - self.drag_start;
if !self.drag_moved && delta.abs() > TOUCH_SLOP {
self.drag_moved = true;
}
if !self.drag_moved {
return;
}
let extent = self.extent();
if extent <= 0.0 {
return;
}
let open = self.snap_targets().last().copied().unwrap_or(1.0);
let target = self.drag_from - self.close_sign() * delta / extent;
self.scrub(ctx, target.max(0.0).min(open));
}
fn release_target(&self, velocity: f64) -> f64 {
let targets = self.snap_targets();
let nearest = |p: f64| {
targets
.iter()
.copied()
.min_by(|a, b| {
(a - p)
.abs()
.partial_cmp(&(b - p).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0.0)
};
if velocity.abs() >= OVERLAY_FLING_VELOCITY {
let opening = velocity * self.close_sign() < 0.0;
let next = if opening {
targets.iter().copied().find(|t| *t > self.progress)
} else {
targets.iter().rev().copied().find(|t| *t < self.progress)
};
if let Some(next) = next {
return next;
}
}
nearest(self.progress)
}
fn settle_drag(&mut self, ctx: &mut EventCtx, target: f64) {
if target <= PROGRESS_EPSILON {
if self.can_dismiss() {
self.request_dismiss(ctx);
} else {
self.begin_ramp(self.open_progress(), false);
ctx.request_redraw();
}
} else {
self.begin_ramp(target, false);
ctx.request_redraw();
}
}
}
impl<State: 'static> View<State> for OverlayModalView<State> {
type Element = OverlayModalWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
let settled = self.config.entrance == OverlayEntrance::None;
OverlayModalWidget {
content: build_child(&self.content, ctx),
config: self.config,
label: self.label.clone(),
dismissable: self.dismissable,
on_dismiss: self.on_dismiss.as_ref().map(erase_callback),
on_close: self.on_close.clone(),
dismiss_signal: None,
last_seen_dismiss: 0,
last_seen_request: self
.dismiss_handle
.as_ref()
.map_or(0, ModalDismiss::generation),
dismiss_handle: self.dismiss_handle.clone(),
pending_result: None,
snap_points: self.snap_points.clone(),
panel: Rect::ZERO,
anim: AnimationController::new(self.config.entrance.duration())
.with_curve(self.config.entrance.curve(false)),
ramp: (0.0, 1.0),
progress: if settled { 1.0 } else { 0.0 },
started: settled,
exiting: false,
closed: false,
dismissed: false,
scrim_captured: false,
scrim_down_outside: false,
close_hovered: false,
close_captured: false,
handle_hovered: false,
handle_captured: false,
drag_captured: false,
drag_start: 0.0,
drag_from: 0.0,
drag_on_handle: false,
drag_moved: false,
tracker: VelocityTracker::new(),
last_frame_time: FrameTime::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut OverlayModalWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = rebuild_child(&prev.content, &self.content, &mut element.content, ctx);
if element.config != self.config {
element.config = self.config;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.label != self.label {
element.label = self.label.clone();
flags |= ChangeFlags::PAINT;
}
if element.dismissable != self.dismissable {
element.dismissable = self.dismissable;
}
if element.snap_points != self.snap_points {
element.snap_points = self.snap_points.clone();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
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());
}
element.on_dismiss = self.on_dismiss.as_ref().map(erase_callback);
flags
}
fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
teardown_child(&self.content, &mut element.content, ctx);
}
}
fn layout_panel(
content: &mut ChildPod,
ctx: &mut LayoutCtx,
config: OverlayModalConfig,
area: Size,
progress: f64,
) -> Rect {
match config.geometry {
OverlayGeometry::Centered { max_width } => {
let available = (area.width - 2.0 * CENTERED_MARGIN).max(0.0);
let width = max_width
.min(available)
.max(OVERLAY_DIALOG_MIN_WIDTH.min(available));
let max_height = (area.height - 2.0 * CENTERED_MARGIN).max(0.0);
let measured = content.layout_child(
ctx,
&BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height)),
);
let height = measured.height.min(max_height);
let origin = Point::new(
((area.width - width) / 2.0).max(0.0),
((area.height - height) / 2.0).max(0.0),
);
let panel = Rect::from_origin_size(origin, Size::new(width, height));
content.set_origin(panel.origin());
panel
}
OverlayGeometry::Edge {
side,
extent,
limit,
} => {
let (axis_area, cross) = if side.is_vertical() {
(area.height, area.width)
} else {
(area.width, area.height)
};
let cap = limit.apply(axis_area, axis_area);
let thickness = match extent {
OverlayExtent::Fraction(f) => limit.apply(axis_area * f, axis_area),
OverlayExtent::Content => {
let bc = if side.is_vertical() {
BoxConstraints::new(Size::new(cross, 0.0), Size::new(cross, cap))
} else {
BoxConstraints::new(Size::new(0.0, cross), Size::new(cap, cross))
};
let measured = content.layout_child(ctx, &bc);
let along = if side.is_vertical() {
measured.height
} else {
measured.width
};
along.min(cap)
}
};
let size = if side.is_vertical() {
Size::new(cross, thickness)
} else {
Size::new(thickness, cross)
};
if extent != OverlayExtent::Content {
content.layout_child(ctx, &BoxConstraints::tight(size));
}
let origin = edge_origin(side, area, size, progress);
let panel = Rect::from_origin_size(origin, size);
content.set_origin(panel.origin());
panel
}
}
}
fn edge_origin(side: OverlaySide, area: Size, size: Size, progress: f64) -> Point {
let thickness = if side.is_vertical() {
size.height
} else {
size.width
};
let out = thickness * (1.0 - progress);
match side {
OverlaySide::Top => Point::new(0.0, -out),
OverlaySide::Bottom => Point::new(0.0, area.height - thickness + out),
OverlaySide::Left => Point::new(-out, 0.0),
OverlaySide::Right => Point::new(area.width - thickness + out, 0.0),
}
}
fn paint_border(
scene: &mut dyn PaintScene,
origin: Point,
size: Size,
border: OverlayBorder,
color: Color,
) {
let half = BORDER_WIDTH / 2.0;
let (from, to) = match border {
OverlayBorder::None => return,
OverlayBorder::All => {
let mut path = BezPath::new();
path.move_to(Point::new(half, half));
path.line_to(Point::new(size.width - half, half));
path.line_to(Point::new(size.width - half, size.height - half));
path.line_to(Point::new(half, size.height - half));
path.close_path();
scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(color));
return;
}
OverlayBorder::Edge(OverlaySide::Top) => {
(Point::new(0.0, half), Point::new(size.width, half))
}
OverlayBorder::Edge(OverlaySide::Bottom) => (
Point::new(0.0, size.height - half),
Point::new(size.width, size.height - half),
),
OverlayBorder::Edge(OverlaySide::Left) => {
(Point::new(half, 0.0), Point::new(half, size.height))
}
OverlayBorder::Edge(OverlaySide::Right) => (
Point::new(size.width - half, 0.0),
Point::new(size.width - half, size.height),
),
};
let mut path = BezPath::new();
path.move_to(from);
path.line_to(to);
scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(color));
}
fn scale_alpha(color: Color, factor: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], c[3] * factor])
}
fn hit(rect: Option<Rect>, pos: Point) -> bool {
rect.is_some_and(|r| r.contains(pos))
}
impl Widget for OverlayModalWidget {
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),
);
self.panel = layout_panel(&mut self.content, ctx, self.config, area, self.progress);
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);
if !ctx.is_hovered() {
self.close_hovered = false;
self.handle_hovered = false;
}
let (origin, size) = (ctx.origin(), ctx.size());
let now = ctx.frame_time();
let (reduce, radius, scrim, fill, border, ink, handle_color, shadow) = {
let theme = Theme::from_paint_ctx(ctx);
(
reduce_motion(theme),
super::radius(theme),
super::scrim(theme),
super::container(theme, self.config.container),
super::outline_variant(theme),
super::on_surface(theme),
super::on_surface_variant(theme),
super::shadow(theme, self.config.elevation),
)
};
self.last_frame_time = now;
if !self.started {
self.started = true;
self.ramp = (0.0, self.open_progress());
if !reduce {
self.anim.forward();
}
}
let next = if reduce {
if self.anim.is_animating() {
self.anim.stop();
}
self.ramp.1
} else if self.anim.is_animating() {
if self.anim.advance(now) {
self.request_continuation(ctx);
}
self.ramp_value()
} else {
self.progress
};
if (next - self.progress).abs() > PROGRESS_EPSILON {
self.progress = next;
self.request_continuation(ctx);
}
if self.exiting && !self.anim.is_animating() && self.progress <= PROGRESS_EPSILON {
self.exiting = false;
self.progress = 0.0;
if let Some(on_close) = self.on_close.clone() {
if on_close.fire_with(&mut self.pending_result) {
self.closed = true;
ctx.request_frame();
} else {
self.begin_ramp(self.open_progress(), false);
self.request_continuation(ctx);
}
}
}
let progress = self.progress;
scene.fill_rect(origin, size, scale_alpha(scrim, progress as f32));
let panel_origin = Point::new(origin.x + self.panel.x0, origin.y + self.panel.y0);
let panel_size = self.panel.size();
let radii = self.config.corners.radii(radius);
let fade_scale = self.config.entrance == OverlayEntrance::FadeScale && progress < 1.0;
if fade_scale {
let layer = Rect::from_origin_size(panel_origin, panel_size)
.inflate(SHADOW_SPILL, SHADOW_SPILL);
scene.push_layer(layer.origin(), layer.size(), progress as f32);
let c = Rect::from_origin_size(panel_origin, panel_size).center();
let scale = SCALE_FROM + (1.0 - SCALE_FROM) * progress;
scene.push_transform(
Affine::translate(c.to_vec2())
* Affine::scale(scale)
* Affine::translate(-c.to_vec2()),
);
}
if let Some((blur, y_offset, color)) = shadow {
scene.draw_shadow(
Point::new(panel_origin.x, panel_origin.y + y_offset),
panel_size,
radii.largest(),
blur,
color,
);
}
scene.fill_rounded_rect_radii(panel_origin, panel_size, radii, fill);
scene.push_clip_rounded_radii(panel_origin, panel_size, radii);
self.content.paint_child(ctx, scene);
scene.pop_clip();
paint_border(scene, panel_origin, panel_size, self.config.border, border);
if self.config.close_button {
let color = if self.close_hovered {
ink
} else {
handle_color
};
let (path, design) = IconData::from(crate::icons::CLOSE).resolve();
let scale = if design > 0.0 {
CLOSE_ICON / design
} else {
1.0
};
let icon_origin = Point::new(
origin.x + self.panel.x1 - CLOSE_INSET - CLOSE_ICON,
origin.y + self.panel.y0 + CLOSE_INSET,
);
scene.fill_path(
icon_origin,
&(Affine::scale(scale) * path),
&Brush::Solid(color),
);
}
if self.config.handle {
let bar = self.handle_bar();
scene.fill_rounded_rect(
Point::new(origin.x + bar.x0, origin.y + bar.y0),
bar.size(),
bar.height() / 2.0,
handle_color,
);
}
if fade_scale {
scene.pop_transform();
scene.pop_layer();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Down) {
ctx.request_focus();
}
let captured = self.close_captured
|| self.handle_captured
|| self.scrim_captured
|| self.drag_captured;
let own_gesture = captured && matches!(event, InputEvent::Pointer(_));
if !own_gesture {
let handled = route_event_single(&mut self.content, ctx, event) == EventResult::Handled;
self.observe_dismiss_request(ctx);
if handled {
return EventResult::Handled;
}
}
if let InputEvent::Key(key) = event {
if key.key == Key::Named(NamedKey::Escape) {
self.request_dismiss(ctx);
}
return EventResult::Handled;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
if !presses(p) && p.phase != PointerPhase::Move {
return EventResult::Handled;
}
let (close, handle) = (self.close_rect(), self.handle_rect());
match p.phase {
PointerPhase::Move => {
if self.drag_captured {
ctx.set_cursor(CursorIcon::Grabbing);
self.drag_move(ctx, p.position);
return EventResult::Handled;
}
if self.close_captured || self.handle_captured || self.scrim_captured {
if self.close_captured || self.handle_captured {
ctx.set_cursor(CursorIcon::Pointer);
}
return EventResult::Handled;
}
let over_close = hit(close, p.position);
let over_handle = hit(handle, p.position);
if over_close || over_handle {
ctx.claim_hover();
if over_handle && self.config.drag {
ctx.set_cursor(CursorIcon::Grab);
} else {
ctx.set_cursor(CursorIcon::Pointer);
}
}
if self.close_hovered != over_close {
self.close_hovered = over_close;
ctx.request_redraw();
}
if self.handle_hovered != over_handle {
self.handle_hovered = over_handle;
ctx.request_redraw();
}
EventResult::Ignored
}
PointerPhase::Down => {
if hit(close, p.position) {
self.close_captured = true;
ctx.capture_pointer();
return EventResult::Handled;
}
if self.config.drag && (hit(handle, p.position) || self.panel.contains(p.position))
{
self.begin_drag(ctx, p.position, hit(handle, p.position));
return EventResult::Handled;
}
if hit(handle, p.position) {
self.handle_captured = true;
ctx.capture_pointer();
return EventResult::Handled;
}
if self.progress <= PROGRESS_EPSILON {
return EventResult::Ignored;
}
self.scrim_captured = true;
self.scrim_down_outside = !self.panel.contains(p.position);
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Up => {
if self.close_captured {
self.close_captured = false;
if hit(close, p.position) {
self.request_dismiss(ctx);
}
return EventResult::Handled;
}
if self.handle_captured {
self.handle_captured = false;
if hit(handle, p.position) {
self.request_dismiss(ctx);
}
return EventResult::Handled;
}
if self.drag_captured {
self.drag_captured = false;
if !self.drag_moved {
if self.drag_on_handle && hit(handle, p.position) {
self.request_dismiss(ctx);
}
return EventResult::Handled;
}
let target = self.release_target(self.tracker.velocity());
self.settle_drag(ctx, target);
return EventResult::Handled;
}
if !self.scrim_captured {
return EventResult::Ignored;
}
self.scrim_captured = false;
let released_outside = !self.panel.contains(p.position);
if self.config.scrim_dismiss && self.scrim_down_outside && released_outside {
self.request_dismiss(ctx);
}
EventResult::Handled
}
PointerPhase::Cancel => {
self.close_captured = false;
self.handle_captured = false;
self.scrim_captured = false;
if self.drag_captured {
self.drag_captured = false;
if self.drag_moved {
self.begin_ramp(self.drag_from, false);
ctx.request_redraw();
}
}
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let label = self.label.clone();
ctx.push_container(
self.config.role.role(),
|node| {
node.set_modal();
if let Some(label) = &label {
node.set_label(label.as_str());
}
},
|ctx| self.content.semantics_child(ctx),
);
}
visit_children!(content);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use frust::authoring::{
KeyEvent, Modifiers, PointerButton, PointerEvent, Shape, any as core_any,
};
use frust::{Brightness, NavigatorView};
use frust_core::RenderRoot;
use frust_widgets::navigator;
use std::any::Any;
const WINDOW: Size = Size::new(400.0, 600.0);
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
panels: Vec<(Point, Size, CornerRadii, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
paths: Vec<(Point, Rect, Color)>,
strokes: Vec<(Rect, f64, Color)>,
shadows: Vec<(Point, Size, f64, f64, Color)>,
clips: Vec<(Point, Size)>,
layers: Vec<f32>,
transforms: Vec<Affine>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
self.rects.push((origin, size, color));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rrects.push((origin, size, radius, color));
}
fn fill_rounded_rect_radii(
&mut self,
origin: Point,
size: Size,
radii: CornerRadii,
color: Color,
) {
self.panels.push((origin, size, radii, color));
}
fn fill_path(&mut self, origin: Point, path: &BezPath, brush: &Brush) {
let color = match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.paths.push((origin, path.bounding_box(), color));
}
fn stroke_path(&mut self, origin: Point, path: &BezPath, width: f64, brush: &Brush) {
let color = match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.strokes
.push((path.bounding_box() + origin.to_vec2(), width, color));
}
fn draw_shadow(
&mut self,
origin: Point,
size: Size,
radius: f64,
std_dev: f64,
color: Color,
) {
self.shadows.push((origin, size, radius, std_dev, color));
}
fn push_clip(&mut self, origin: Point, size: Size) {
self.clips.push((origin, size));
}
fn push_clip_rounded(&mut self, origin: Point, size: Size, _radius: f64) {
self.clips.push((origin, size));
}
fn push_clip_rounded_radii(&mut self, origin: Point, size: Size, _radii: CornerRadii) {
self.clips.push((origin, size));
}
fn pop_clip(&mut self) {}
fn push_layer(&mut self, _origin: Point, _size: Size, alpha: f32) {
self.layers.push(alpha);
}
fn pop_layer(&mut self) {}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {}
}
struct Block(Size);
struct BlockWidget(Size);
impl<S: 'static> View<S> for Block {
type Element = BlockWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> BlockWidget {
BlockWidget(self.0)
}
fn rebuild(
&self,
_prev: &Self,
element: &mut BlockWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.0 = self.0;
ChangeFlags::NONE
}
}
impl Widget for BlockWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.0)
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn escape() -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
}
#[derive(Default)]
struct Flags {
dismissed: u32,
}
fn build(view: &OverlayModalView<Flags>) -> OverlayModalWidget {
let mut counter = 0u64;
View::<Flags>::build(view, &mut BuildCtx::new(&mut counter))
}
fn rebuild(
view: &OverlayModalView<Flags>,
prev: &OverlayModalView<Flags>,
w: &mut OverlayModalWidget,
) {
let mut counter = 0u64;
View::<Flags>::rebuild(view, prev, w, &mut BuildCtx::new(&mut counter));
}
fn layout(w: &mut OverlayModalWidget) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
}
fn dispatch(w: &mut OverlayModalWidget, state: &mut Flags, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
w.event(&mut ctx, event)
}
fn view(config: OverlayModalConfig) -> OverlayModalView<Flags> {
overlay_modal(Block(Size::new(200.0, 120.0)), config)
.on_dismiss(|s: &mut Flags| s.dismissed += 1)
}
fn staged(config: OverlayModalConfig) -> (OverlayModalView<Flags>, Rc<Cell<u32>>) {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let view = view(config).on_close(move || hook.set(hook.get() + 1));
(view, closed)
}
fn frame(w: &mut OverlayModalWidget, ms: f64) -> Recorder {
let mut rec = Recorder::default();
let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(ms));
w.paint(&mut ctx, &mut rec);
rec
}
fn run_ramp(w: &mut OverlayModalWidget, from_ms: f64) {
frame(w, from_ms);
layout(w);
frame(w, from_ms + 2000.0);
layout(w);
}
fn settled(config: OverlayModalConfig) -> OverlayModalConfig {
config.entrance(OverlayEntrance::None)
}
#[test]
fn a_centered_panel_is_capped_centred_and_content_tall() {
let mut w = build(&view(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
))));
let size = layout(&mut w);
assert_eq!(size, WINDOW, "the host fills its area (the scrim)");
let panel = w.panel_rect();
assert_eq!(panel.width(), WINDOW.width - 2.0 * CENTERED_MARGIN);
assert_eq!(panel.height(), 120.0, "content-tall");
assert!((panel.center().x - WINDOW.width / 2.0).abs() < 1e-9);
assert!((panel.center().y - WINDOW.height / 2.0).abs() < 1e-9);
assert_eq!(w.content.origin(), panel.origin());
}
#[test]
fn a_centered_panel_holds_the_m3_minimum_width_where_it_fits() {
let mut w = build(&view(settled(OverlayModalConfig::centered(120.0))));
layout(&mut w);
assert_eq!(w.panel_rect().width(), OVERLAY_DIALOG_MIN_WIDTH);
}
#[test]
fn an_edge_panel_pins_to_its_side_at_three_quarters_capped_at_the_side_sheet_max() {
for side in [OverlaySide::Left, OverlaySide::Right] {
let mut w = build(&view(settled(OverlayModalConfig::edge(side))));
layout(&mut w);
let panel = w.panel_rect();
assert_eq!(
panel.width(),
(WINDOW.width * OVERLAY_EDGE_FRACTION).min(OVERLAY_SIDE_SHEET_MAX_WIDTH)
);
assert_eq!(panel.height(), WINDOW.height, "full height");
match side {
OverlaySide::Left => assert_eq!(panel.x0, 0.0),
_ => assert_eq!(panel.x1, WINDOW.width),
}
}
for side in [OverlaySide::Top, OverlaySide::Bottom] {
let mut w = build(&view(settled(OverlayModalConfig::edge(side))));
layout(&mut w);
let panel = w.panel_rect();
assert_eq!(panel.width(), WINDOW.width, "full width");
assert_eq!(panel.height(), 120.0, "content-tall");
match side {
OverlaySide::Top => assert_eq!(panel.y0, 0.0),
_ => assert_eq!(panel.y1, WINDOW.height),
}
}
}
#[test]
fn each_edge_preset_rounds_only_the_corners_facing_the_screen() {
let r = 28.0;
assert_eq!(
OverlayModalConfig::edge(OverlaySide::Bottom)
.corners
.radii(r),
CornerRadii::new(r, r, 0.0, 0.0)
);
assert_eq!(
OverlayModalConfig::edge(OverlaySide::Right)
.corners
.radii(r),
CornerRadii::new(r, 0.0, 0.0, r)
);
assert_eq!(
OverlayCorners::None.radii(r),
CornerRadii::new(0.0, 0.0, 0.0, 0.0)
);
}
#[test]
fn a_limit_caps_an_edge_panel() {
let config = settled(OverlayModalConfig::edge(OverlaySide::Bottom).extent(
OverlayExtent::Content,
OverlayLimit::Fraction(OVERLAY_SHEET_MAX_HEIGHT_FRACTION),
));
let mut w = build(&overlay_modal(Block(Size::new(200.0, 5000.0)), config));
layout(&mut w);
assert_eq!(
w.panel_rect().height(),
WINDOW.height * OVERLAY_SHEET_MAX_HEIGHT_FRACTION
);
}
#[test]
fn an_unbounded_height_collapses_the_host_and_its_panel_to_nothing() {
let mut w = build(&view(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
))));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let scrolled = BoxConstraints::new(
Size::new(WINDOW.width, 0.0),
Size::new(WINDOW.width, f64::INFINITY),
);
let size = w.layout(&mut lctx, &scrolled);
assert_eq!(size.height, 0.0, "no vertical area to fill");
assert_eq!(
w.panel_rect().height(),
0.0,
"the panel is a hairline, while the barrier still takes presses"
);
let bounded = w.layout(&mut lctx, &BoxConstraints::tight(WINDOW));
assert_eq!(bounded, WINDOW);
assert_eq!(w.panel_rect().height(), 120.0);
}
#[test]
fn the_themed_chrome_is_material_roles_end_to_end() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let scheme = theme.scheme();
let mut w = build(&view(
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
.close_button(true)
.border(OverlayBorder::All),
));
let size = layout(&mut w);
let mut rec = Recorder::default();
let mut ctx =
PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.rects[0].1, size, "the scrim covers the area");
assert_eq!(
rec.rects[0].2,
super::super::with_alpha(scheme.scrim, super::super::OVERLAY_SCRIM_ALPHA),
"the M3 32% scrim"
);
assert_eq!(
rec.panels[0].3, scheme.surface_container_high,
"the dialog container role"
);
assert_eq!(
rec.panels[0].2,
CornerRadii::uniform(theme.shape.extra_large),
"`shape.extra_large` on all four corners"
);
let level3 = theme.elevation.level3.shadow(theme.brightness);
assert_eq!(rec.shadows[0].3, level3.blur_std_dev, "the level-3 rung");
assert_eq!(rec.strokes[0].2, scheme.outline_variant, "the hairline");
assert_eq!(
rec.paths[0].2, scheme.on_surface_variant,
"the close icon's rest role"
);
assert_eq!(rec.clips.len(), 1, "the content is clipped to the panel");
}
#[test]
fn the_unthemed_chrome_falls_back_to_the_m3_baseline_light_values() {
let mut w = build(&view(
settled(OverlayModalConfig::edge(OverlaySide::Bottom)).handle(true),
));
layout(&mut w);
let rec = frame(&mut w, 0.0);
assert_eq!(rec.rects[0].2, super::super::scrim(None));
assert_eq!(
rec.panels[0].3,
super::super::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.2, HANDLE_HEIGHT / 2.0);
assert_eq!(handle.3, super::super::on_surface_variant(None));
assert!(rec.strokes.is_empty(), "no border by default");
}
#[test]
fn the_close_and_handle_hit_boxes_are_the_m3_touch_target() {
let mut w = build(&view(
settled(OverlayModalConfig::edge(OverlaySide::Bottom))
.close_button(true)
.handle(true),
));
layout(&mut w);
let close = w.close_rect().expect("a close box");
assert_eq!(close.size(), Size::new(CLOSE_HIT, CLOSE_HIT));
assert_eq!(
close.center().x,
w.panel_rect().x1 - CLOSE_INSET - CLOSE_ICON / 2.0
);
let handle = w.handle_rect().expect("a handle box");
assert_eq!(handle.size(), Size::new(CLOSE_HIT, CLOSE_HIT));
assert_eq!(handle.center().x, w.panel_rect().center().x);
assert_eq!(OVERLAY_HANDLE_RESERVE, HANDLE_TOUCH_TARGET);
}
#[test]
fn a_scrim_press_and_release_outside_the_panel_dismisses() {
let mut w = build(&view(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
))));
layout(&mut w);
let mut state = Flags::default();
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0)),
EventResult::Handled,
"the barrier swallows the press"
);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 1);
}
#[test]
fn a_press_on_the_panel_background_is_swallowed_without_dismissing() {
let mut w = build(&view(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
))));
layout(&mut w);
let c = w.panel_rect().center();
let mut state = Flags::default();
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
EventResult::Handled
);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
assert_eq!(state.dismissed, 0);
}
#[test]
fn scrim_dismiss_off_ignores_the_same_gesture_but_escape_still_works() {
let mut w = build(&view(
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).scrim_dismiss(false),
));
layout(&mut w);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 0);
dispatch(&mut w, &mut state, &escape());
assert_eq!(state.dismissed, 1);
}
#[test]
fn dismissable_false_gates_every_gesture_at_once() {
let mut w = build(
&view(
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true),
)
.dismissable(false),
);
layout(&mut w);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
dispatch(&mut w, &mut state, &escape());
let close = w.close_rect().expect("a close box").center();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, close.x, close.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, close.x, close.y),
);
assert_eq!(
state.dismissed, 0,
"the scrim tap, Escape and the close affordance are all gated together"
);
}
#[test]
fn the_close_affordance_fires_on_release_inside_and_hovers() {
let mut w = build(&view(
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true),
));
layout(&mut w);
let close = w.close_rect().expect("a close box");
let c = close.center();
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
assert!(w.close_hovered, "the latch follows the hit test");
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
assert_eq!(state.dismissed, 0, "never on down");
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
assert_eq!(state.dismissed, 1);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 1);
}
#[test]
fn a_hovered_close_affordance_steps_up_to_the_on_surface_role() {
let config =
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true);
let mut probe = build(&view(config));
layout(&mut probe);
let c = probe.close_rect().expect("a close box").center();
let mut root: RenderRoot<Flags, OverlayModalView<Flags>> = RenderRoot::new();
let mut state = Flags::default();
let mut tcx = TextContext::new();
let mut app = move |_s: &mut Flags| overlay_modal(Block(Size::new(200.0, 120.0)), config);
let theme = crate::baseline().with_brightness(Brightness::Light);
root.set_theme(Box::new(theme.clone()));
root.rebuild(&mut app, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let mut rest = Recorder::default();
root.paint(&mut rest, FrameTime::ZERO);
assert_eq!(
rest.paths[0].2,
theme.scheme().on_surface_variant,
"at rest the affordance sits in the muted ink role"
);
root.event(&mut state, &pointer(PointerPhase::Move, c.x, c.y));
let mut hovered = Recorder::default();
root.paint(&mut hovered, FrameTime::ZERO);
assert_eq!(
hovered.paths[0].2,
theme.scheme().on_surface,
"a hovered affordance steps up to the panel's own ink"
);
}
#[test]
fn the_drag_handle_dismisses_on_release() {
let mut w = build(&view(
settled(OverlayModalConfig::edge(OverlaySide::Bottom)).handle(true),
));
layout(&mut w);
let handle = w.handle_rect().expect("a handle box");
let c = handle.center();
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
assert_eq!(state.dismissed, 1);
}
#[test]
fn a_cancelled_press_clears_every_latch_without_dismissing() {
let mut w = build(&view(
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true),
));
layout(&mut w);
let c = w.close_rect().unwrap().center();
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y));
assert_eq!(state.dismissed, 0);
assert!(!w.close_captured && !w.scrim_captured);
}
#[test]
fn the_barrier_swallows_a_key_the_content_did_not_take() {
let mut w = build(&view(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
))));
layout(&mut w);
let mut state = Flags::default();
let tab = InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Tab),
modifiers: Modifiers::default(),
repeat: false,
});
assert_eq!(
dispatch(&mut w, &mut state, &tab),
EventResult::Handled,
"a modal barrier absorbs keys as well as pointers"
);
assert_eq!(state.dismissed, 0, "and only Escape dismisses");
}
#[test]
fn the_fade_scale_entrance_ramps_alpha_and_scale_then_settles() {
let mut w = build(&view(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
)));
layout(&mut w);
let first = frame(&mut w, 0.0);
assert_eq!(first.layers, vec![0.0], "starts transparent");
assert_eq!(first.rects[0].2.components[3], 0.0, "so does the scrim");
let mid = frame(&mut w, MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
let alpha = mid.layers[0];
assert!(alpha > 0.0 && alpha < 1.0, "mid-fade: {alpha}");
assert_eq!(mid.transforms.len(), 1, "and a scale transform");
let done = frame(&mut w, 1000.0);
assert!(
done.layers.is_empty() && done.transforms.is_empty(),
"a settled panel composites plainly"
);
assert!((w.progress() - 1.0).abs() < 1e-9);
}
#[test]
fn the_entrance_durations_are_the_mapped_material_presets() {
assert_eq!(
OverlayEntrance::FadeScale.duration(),
MaterialMotion::SHORT_4
);
assert_eq!(OverlayEntrance::Slide.duration(), MaterialMotion::LONG_2);
assert_eq!(OverlayEntrance::None.duration(), Duration::ZERO);
assert_eq!(
OverlayEntrance::FadeScale.curve(false),
MaterialMotion::EMPHASIZED_DECELERATE
);
assert_eq!(
OverlayEntrance::Slide.curve(false),
MaterialMotion::EMPHASIZED
);
assert_eq!(
OverlayEntrance::Slide.curve(true),
MaterialMotion::EMPHASIZED_ACCELERATE,
"either entrance leaves on M3's accelerate curve"
);
}
#[test]
fn reduce_motion_collapses_the_entrance_to_a_jump() {
let mut theme = crate::baseline().with_brightness(Brightness::Light);
theme.motion.reduce_motion = true;
let mut w = build(&view(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
)));
let size = layout(&mut w);
let mut rec = Recorder::default();
let mut ctx =
PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
assert!((w.progress() - 1.0).abs() < 1e-9, "settled on the spot");
assert!(rec.layers.is_empty(), "no fade layer");
assert_eq!(
rec.rects[0].2,
super::super::with_alpha(theme.scheme().scrim, super::super::OVERLAY_SCRIM_ALPHA),
"a full-strength scrim"
);
}
#[test]
fn the_slide_entrance_moves_the_panel_in_layout() {
let mut w = build(&view(OverlayModalConfig::edge(OverlaySide::Right)));
layout(&mut w);
assert_eq!(
w.panel_rect().x0,
WINDOW.width,
"starts entirely off the right edge"
);
let step = |w: &mut OverlayModalWidget, ms: f64| {
frame(w, ms);
layout(w);
};
step(&mut w, 0.0);
step(&mut w, MaterialMotion::LONG_2.as_millis() as f64 / 2.0);
let mid = w.panel_rect();
assert!(
mid.x0 > WINDOW.width - mid.width() && mid.x0 < WINDOW.width,
"mid-slide: {}",
mid.x0
);
step(&mut w, 2000.0);
assert_eq!(w.panel_rect().x1, WINDOW.width, "flush at rest");
}
#[test]
fn each_entrances_seeding_paint_asks_for_the_pass_its_own_motion_needs() {
for (config, layout_affecting) in [
(OverlayModalConfig::edge(OverlaySide::Right), true),
(
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
false,
),
] {
let mut w = build(&view(config));
let size = layout(&mut w);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, ft_ms(0.0));
w.paint(&mut ctx, &mut Recorder::default());
assert!(
w.progress().abs() < 1e-9,
"the seeding paint leaves progress at zero"
);
assert!(
ctx.needs_frame(),
"the seeding paint must schedule the frame that ramps progress off zero"
);
assert_eq!(
ctx.needs_layout(),
layout_affecting,
"only a layout-affecting entrance asks for a layout pass"
);
}
}
#[test]
fn a_dismiss_ramps_the_panel_out_and_closes_only_when_it_settles() {
let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 0, "the unstaged callback stays unused");
assert_eq!(closed.get(), 0, "and the close is deferred");
assert!(w.is_exiting());
frame(&mut w, 3000.0);
let mid = frame(
&mut w,
3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
);
assert!(w.progress() > 0.0 && w.progress() < 1.0, "{}", w.progress());
let alpha = mid.layers[0];
assert!(alpha > 0.0 && alpha < 1.0, "the panel fades out: {alpha}");
assert!(
mid.rects[0].2.components[3] < super::super::OVERLAY_SCRIM_ALPHA,
"and the scrim rides the ramp down"
);
assert_eq!(closed.get(), 0);
frame(&mut w, 5000.0);
assert_eq!(w.progress(), 0.0);
assert_eq!(closed.get(), 1);
assert!(!w.is_exiting());
assert_eq!(state.dismissed, 0);
}
#[test]
fn a_panel_that_has_finished_its_exit_swallows_no_press() {
let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
let over_panel = w.panel_rect().center();
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
frame(&mut w, 3000.0);
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1, "the staged close fired");
assert_eq!(w.progress(), 0.0, "and the panel paints nothing");
let result = dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, over_panel.x, over_panel.y),
);
assert_eq!(
result,
EventResult::Ignored,
"an invisible panel absorbs nothing"
);
assert!(!w.scrim_captured, "and arms no scrim dismiss either");
}
#[test]
fn the_staged_close_hook_survives_a_rebuild_and_still_fires_exactly_once() {
let closed = Rc::new(Cell::new(0u32));
let config = OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH);
let hook1 = closed.clone();
let v1 = StagedExit {
inner: view(config),
on_close: Rc::new(StagedPop::unguarded(move || hook1.set(hook1.get() + 1))),
dismiss_signal: None,
};
let mut counter = 0u64;
let mut w = View::<Flags>::build(&v1, &mut BuildCtx::new(&mut counter));
layout(&mut w);
run_ramp(&mut w, 0.0);
let hook2 = closed.clone();
let v2 = StagedExit {
inner: view(config),
on_close: Rc::new(StagedPop::unguarded(move || hook2.set(hook2.get() + 1))),
dismiss_signal: None,
};
View::<Flags>::rebuild(&v2, &v1, &mut w, &mut BuildCtx::new(&mut counter));
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
frame(&mut w, 3000.0);
frame(&mut w, 5000.0);
assert_eq!(
closed.get(),
1,
"the reinstalled hook still fires exactly once"
);
}
#[test]
fn a_rebuild_never_resurrects_or_clears_a_bare_views_on_close_hook() {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let v1 = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
.on_close(move || hook.set(hook.get() + 1));
let mut w = build(&v1);
layout(&mut w);
run_ramp(&mut w, 0.0);
let v2 = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
rebuild(&v2, &v1, &mut w);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
frame(&mut w, 3000.0);
frame(&mut w, 5000.0);
assert_eq!(
closed.get(),
1,
"the build-time hook is still installed and still fires — pinned, not cleared"
);
}
#[test]
fn the_exit_keeps_asking_for_the_pass_its_own_motion_needs() {
for (config, layout_affecting) in [
(OverlayModalConfig::edge(OverlaySide::Bottom), true),
(
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
false,
),
] {
let (view, _closed) = staged(config);
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(3000.0));
w.paint(&mut ctx, &mut Recorder::default());
assert!(
ctx.needs_frame(),
"the exit's seeding paint schedules its own continuation"
);
assert_eq!(
ctx.needs_layout(),
layout_affecting,
"only a layout-affecting exit asks for a layout pass"
);
}
}
#[test]
fn reduce_motion_closes_on_the_spot_with_no_ramp() {
let mut theme = crate::baseline().with_brightness(Brightness::Light);
theme.motion.reduce_motion = true;
let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let mut w = build(&view);
let size = layout(&mut w);
let paint = |w: &mut OverlayModalWidget, ms: f64| {
let mut ctx =
PaintCtx::for_test(Point::ORIGIN, size, ft_ms(ms)).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut Recorder::default());
};
paint(&mut w, 0.0);
assert!((w.progress() - 1.0).abs() < 1e-9, "no entrance ramp");
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
paint(&mut w, 16.0);
assert_eq!(w.progress(), 0.0, "the exit is a jump, not a ramp");
assert_eq!(closed.get(), 1);
}
#[test]
fn a_second_dismiss_while_exiting_is_a_no_op() {
let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
let first = w.progress();
frame(&mut w, 3000.0);
frame(
&mut w,
3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
);
let mid = w.progress();
assert!(mid < first, "the ramp is running");
dispatch(&mut w, &mut state, &escape());
assert_eq!(w.progress(), mid, "the ramp is untouched");
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1, "one close for two triggers");
assert_eq!(state.dismissed, 0);
}
#[test]
fn a_dismiss_trigger_after_settle_does_not_fire_on_close_again() {
let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
frame(&mut w, 3000.0);
frame(&mut w, 5000.0);
assert_eq!(w.progress(), 0.0, "settled closed");
assert_eq!(closed.get(), 1, "on_close fired once on settle");
assert!(!w.is_exiting());
dispatch(&mut w, &mut state, &escape());
frame(&mut w, 7000.0);
assert_eq!(closed.get(), 1, "on_close does not fire a second time");
assert_eq!(state.dismissed, 0);
}
#[test]
fn without_a_close_hook_the_dismissal_stays_unstaged() {
let mut w = build(&view(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
)));
layout(&mut w);
run_ramp(&mut w, 0.0);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
assert_eq!(state.dismissed, 1);
assert!(!w.is_exiting());
assert!((w.progress() - 1.0).abs() < 1e-9, "nothing animates out");
}
fn back_wired(
config: OverlayModalConfig,
) -> (OverlayModalWidget, Rc<Cell<u64>>, Rc<Cell<u32>>) {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let signal = Rc::new(Cell::new(0u64));
let staged = StagedExit {
inner: view(config),
on_close: Rc::new(StagedPop::unguarded(move || hook.set(hook.get() + 1))),
dismiss_signal: Some(signal.clone()),
};
let mut counter = 0u64;
let mut w = View::<Flags>::build(&staged, &mut BuildCtx::new(&mut counter));
layout(&mut w);
run_ramp(&mut w, 0.0);
(w, signal, closed)
}
#[test]
fn a_dismiss_signal_bump_stages_the_same_exit_ramp_every_gesture_takes() {
let (mut w, signal, closed) =
back_wired(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
signal.set(signal.get() + 1);
frame(&mut w, 3000.0);
assert!(w.is_exiting(), "the back press stages the exit, not a pop");
assert_eq!(closed.get(), 0, "and fires nothing yet");
frame(
&mut w,
3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
);
assert!(w.progress() > 0.0 && w.progress() < 1.0, "mid-exit");
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1, "the pop fires when the ramp settles");
frame(&mut w, 6000.0);
assert_eq!(closed.get(), 1);
}
#[test]
fn a_back_press_on_an_unstageable_modal_fires_the_pop_and_schedules_its_drain() {
let (mut w, signal, closed) = back_wired(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
)));
signal.set(signal.get() + 1);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(3000.0));
w.paint(&mut ctx, &mut Recorder::default());
assert_eq!(closed.get(), 1);
assert!(
ctx.needs_frame(),
"the firing paint schedules the draining frame"
);
}
#[test]
fn a_non_dismissable_modal_ignores_a_dismiss_signal_bump() {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let signal = Rc::new(Cell::new(0u64));
let staged = StagedExit {
inner: view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).dismissable(false),
on_close: Rc::new(StagedPop::unguarded(move || hook.set(hook.get() + 1))),
dismiss_signal: Some(signal.clone()),
};
let mut counter = 0u64;
let mut w = View::<Flags>::build(&staged, &mut BuildCtx::new(&mut counter));
layout(&mut w);
run_ramp(&mut w, 0.0);
signal.set(signal.get() + 1);
frame(&mut w, 3000.0);
assert!(!w.is_exiting());
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 0, "a Veto-shaped modal never self-pops");
}
#[test]
fn a_bump_that_predates_the_widget_is_not_a_back_press_aimed_at_it() {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let signal = Rc::new(Cell::new(7u64));
let staged = StagedExit {
inner: view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)),
on_close: Rc::new(StagedPop::unguarded(move || hook.set(hook.get() + 1))),
dismiss_signal: Some(signal.clone()),
};
let mut counter = 0u64;
let mut w = View::<Flags>::build(&staged, &mut BuildCtx::new(&mut counter));
layout(&mut w);
run_ramp(&mut w, 0.0);
assert!(
!w.is_exiting(),
"the pre-existing generation is the baseline"
);
assert_eq!(closed.get(), 0);
}
fn app_dismissable(
config: OverlayModalConfig,
dismissable: bool,
) -> (OverlayModalWidget, ModalDismiss, Rc<Cell<u32>>) {
let (view, closed) = staged(config);
let dismiss = ModalDismiss::new();
let view = view
.dismissable(dismissable)
.dismiss_handle(dismiss.clone());
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
(w, dismiss, closed)
}
#[test]
fn a_dismiss_request_stages_the_same_exit_ramp_every_gesture_takes() {
let (mut w, dismiss, closed) =
app_dismissable(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH), true);
assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
dismiss.dismiss();
frame(&mut w, 3000.0);
assert!(w.is_exiting(), "the request stages the exit, not a pop");
assert_eq!(closed.get(), 0, "and fires nothing yet");
frame(
&mut w,
3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
);
assert!(w.progress() > 0.0 && w.progress() < 1.0, "mid-exit");
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1, "the pop fires when the ramp settles");
frame(&mut w, 6000.0);
assert_eq!(closed.get(), 1);
}
#[test]
fn a_second_dismiss_request_while_exiting_is_a_no_op() {
let (mut w, dismiss, closed) =
app_dismissable(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH), true);
dismiss.dismiss();
frame(&mut w, 3000.0);
frame(
&mut w,
3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
);
let mid = w.progress();
assert!(mid < 1.0, "the ramp is running");
dismiss.dismiss_with(PopResult::of(5));
frame(
&mut w,
3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
);
assert!(w.is_exiting(), "still the one exit");
assert!(
dismiss.take_result(dismiss.generation()).is_some(),
"the refused request's payload stays with the handle"
);
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1, "one close for two requests");
dismiss.dismiss();
frame(&mut w, 7000.0);
assert_eq!(closed.get(), 1);
}
#[test]
fn a_dismiss_request_closes_a_non_dismissable_modal() {
let (mut w, dismiss, closed) = app_dismissable(
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
false,
);
let mut state = Flags::default();
dispatch(&mut w, &mut state, &escape());
assert!(!w.is_exiting(), "the user's own exits are still gone");
dismiss.dismiss();
frame(&mut w, 3000.0);
assert!(w.is_exiting(), "but the app's own control still closes it");
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1);
assert_eq!(state.dismissed, 0);
}
#[test]
fn a_request_that_predates_the_widget_is_not_aimed_at_it() {
let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let dismiss = ModalDismiss::new();
dismiss.dismiss();
dismiss.dismiss();
let mut w = build(&view.dismiss_handle(dismiss.clone()));
layout(&mut w);
run_ramp(&mut w, 0.0);
assert!(!w.is_exiting(), "the standing generation is the baseline");
assert_eq!(closed.get(), 0);
dismiss.dismiss();
frame(&mut w, 3000.0);
assert!(w.is_exiting());
}
#[test]
fn an_unstageable_modal_fires_the_request_at_once_and_schedules_its_drain() {
let (view, closed) = staged(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
)));
let dismiss = ModalDismiss::new();
let mut w = build(&view.dismiss_handle(dismiss.clone()));
layout(&mut w);
dismiss.dismiss();
let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(3000.0));
w.paint(&mut ctx, &mut Recorder::default());
assert_eq!(closed.get(), 1);
assert!(
ctx.needs_frame(),
"the firing paint schedules the draining frame"
);
}
struct Trigger(ModalDismiss);
struct TriggerWidget(ModalDismiss);
impl<S: 'static> View<S> for Trigger {
type Element = TriggerWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> TriggerWidget {
TriggerWidget(self.0.clone())
}
fn rebuild(
&self,
_prev: &Self,
element: &mut TriggerWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.0 = self.0.clone();
ChangeFlags::NONE
}
}
impl Widget for TriggerWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(200.0, 120.0))
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => EventResult::Handled,
PointerPhase::Up => {
self.0.dismiss();
EventResult::Handled
}
_ => EventResult::Ignored,
}
}
}
#[test]
fn a_content_action_stages_its_dismiss_in_the_same_event_pass() {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let dismiss = ModalDismiss::new();
let view = overlay_modal::<Flags, _>(
Trigger(dismiss.clone()),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.on_close(move || hook.set(hook.get() + 1))
.dismiss_handle(dismiss);
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
let hit = w.panel_rect().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, hit.x, hit.y),
);
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
let result = w.event(&mut ctx, &pointer(PointerPhase::Up, hit.x, hit.y));
assert_eq!(result, EventResult::Handled);
assert!(w.is_exiting(), "staged inside the pass that asked for it");
assert!(
ctx.needs_redraw(),
"and the ramp's first frame is asked for"
);
assert_eq!(closed.get(), 0, "nothing pops mid-ramp");
frame(&mut w, 3000.0);
frame(&mut w, 5000.0);
assert_eq!(closed.get(), 1);
assert_eq!(state.dismissed, 0, "the staged path, not the unstaged one");
}
#[test]
fn a_stack_mounted_panels_handle_reaches_the_unstaged_on_dismiss() {
let dismiss = ModalDismiss::new();
let view = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
.dismiss_handle(dismiss.clone());
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
dismiss.dismiss();
frame(&mut w, 3000.0);
let mut state = Flags::default();
assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.dismissed, 1,
"a `Stack` mount's handle is not inert — it dismisses unstaged"
);
assert!(!w.is_exiting(), "there was nothing to stage");
frame(&mut w, 3100.0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 6.0, 6.0));
assert_eq!(state.dismissed, 1);
}
#[test]
fn escape_with_a_pending_unobserved_request_dismisses_exactly_once() {
let dismiss = ModalDismiss::new();
let view = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
.dismiss_handle(dismiss.clone());
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
dismiss.dismiss();
frame(&mut w, 3000.0);
let mut state = Flags::default();
assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
assert_eq!(
dispatch(&mut w, &mut state, &escape()),
EventResult::Handled,
"the barrier still swallows the key"
);
assert_eq!(
state.dismissed, 1,
"one keypress over a pending request is one dismissal, not two"
);
}
#[test]
fn a_stack_mounted_dialog_view_dismisses_through_its_installed_handle() {
let dismiss = ModalDismiss::new();
let view = crate::dialog::dialog::<Flags>()
.title("Discard draft?")
.on_dismiss(|s: &mut Flags| s.dismissed += 1)
.dismiss_handle(dismiss.clone());
let mut counter = 0u64;
let mut w = View::<Flags>::build(&view, &mut BuildCtx::new(&mut counter));
layout(&mut w);
run_ramp(&mut w, 0.0);
dismiss.dismiss();
let mut state = Flags::default();
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 5.0, 5.0));
assert_eq!(state.dismissed, 1);
}
#[test]
fn a_rebuild_that_swaps_the_handle_relatches_against_the_new_cell() {
let (first_view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
let first = ModalDismiss::new();
let first_view = first_view.dismiss_handle(first.clone());
let mut w = build(&first_view);
layout(&mut w);
run_ramp(&mut w, 0.0);
let second = ModalDismiss::new();
second.dismiss();
let second_view = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
.dismiss_handle(second.clone());
rebuild(&second_view, &first_view, &mut w);
frame(&mut w, 3000.0);
assert!(!w.is_exiting(), "the swap itself is not a request");
first.dismiss();
frame(&mut w, 3100.0);
assert!(!w.is_exiting());
second.dismiss();
frame(&mut w, 3200.0);
assert!(w.is_exiting());
frame(&mut w, 6000.0);
assert_eq!(closed.get(), 1);
}
#[derive(Default)]
struct NavState {
results: Vec<Option<i32>>,
}
struct BgPage(Size);
struct BgPageWidget(Size);
impl View<NavState> for BgPage {
type Element = BgPageWidget;
fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageWidget {
BgPageWidget(self.0)
}
fn rebuild(&self, _p: &Self, _e: &mut BgPageWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for BgPageWidget {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.0)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
struct NavHarness {
controller: NavigatorController<NavState>,
root: RenderRoot<NavState, NavigatorView<NavState>>,
state: NavState,
tcx: TextContext,
}
impl NavHarness {
fn new() -> Self {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = NavHarness {
controller,
root: RenderRoot::new(),
state: NavState::default(),
tcx: TextContext::new(),
};
h.pass();
h
}
fn app(&self) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> + use<> {
let ctrl = self.controller.clone();
move |_: &mut NavState| navigator(&ctrl, || core_any::<NavState, _>(BgPage(WINDOW)))
}
fn pass(&mut self) {
let mut app = self.app();
self.root.rebuild(&mut app, &mut self.state);
self.root
.layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
}
fn paint(&mut self, ms: u64) -> bool {
self.root
.paint(&mut Recorder::default(), ft_ms(ms as f64))
.needs_frame
}
fn drive(&mut self, from_ms: u64) {
for i in 0..20u64 {
self.pass();
if !self.paint(from_ms + i * 100) {
return;
}
}
panic!("the frame loop never settled");
}
}
#[test]
fn show_overlay_modal_pushes_a_transparent_page_that_pops_on_a_staged_dismiss() {
let mut h = NavHarness::new();
show_overlay_modal(
&h.controller,
|| {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
assert_eq!(h.controller.depth(), 2, "the modal is up");
h.root
.event(&mut h.state, &pointer(PointerPhase::Down, 5.0, 5.0));
h.root
.event(&mut h.state, &pointer(PointerPhase::Up, 5.0, 5.0));
assert_eq!(
h.controller.depth(),
2,
"nothing pops during the exit ramp itself"
);
h.drive(3000);
assert_eq!(h.state.results, vec![None], "popped with an empty result");
assert_eq!(h.controller.depth(), 1);
}
#[test]
fn a_page_pushed_during_the_exit_ramp_is_not_popped_in_the_modals_place() {
let mut h = NavHarness::new();
show_overlay_modal(
&h.controller,
|| {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
h.root
.event(&mut h.state, &pointer(PointerPhase::Down, 5.0, 5.0));
h.root
.event(&mut h.state, &pointer(PointerPhase::Up, 5.0, 5.0));
h.pass();
h.paint(3000);
h.controller
.push_transparent(|| core_any::<NavState, _>(BgPage(Size::new(400.0, 100.0))));
h.drive(3100);
assert_eq!(
h.controller.depth(),
3,
"the pushed page survives — the staged pop was refused, not aimed at it"
);
assert!(
h.state.results.is_empty(),
"and nothing reported a dismissal"
);
h.controller.request_back();
h.drive(6000);
assert_eq!(
h.controller.depth(),
2,
"the pushed page backs out normally"
);
h.controller.request_back();
h.drive(9000);
assert_eq!(
h.state.results,
vec![None],
"the modal is still dismissible after the refused pop"
);
assert_eq!(h.controller.depth(), 1);
}
#[test]
fn a_back_request_dismisses_a_dismissable_modal_through_the_navigator() {
let mut h = NavHarness::new();
show_overlay_modal(
&h.controller,
|| {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
assert!(
h.controller.back_interest(),
"a dismissable modal claims back interest"
);
h.controller.request_back();
h.pass();
assert_eq!(
h.controller.depth(),
2,
"DismissAnimated: the stack is unchanged immediately"
);
assert!(
h.paint(3000),
"the staging paint schedules its own continuation"
);
assert_eq!(h.controller.depth(), 2, "still up, mid-ramp");
h.drive(4000);
assert_eq!(
h.state.results,
vec![None],
"a back request dismisses the modal"
);
assert_eq!(h.controller.depth(), 1);
}
#[test]
fn a_back_request_on_a_non_dismissable_modal_is_vetoed() {
let mut h = NavHarness::new();
show_overlay_modal(
&h.controller,
|| {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.dismissable(false)
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
assert!(
h.controller.back_interest(),
"a Veto modal still claims the back press"
);
h.controller.request_back();
h.pass();
h.paint(3000);
h.paint(6000);
assert!(
h.state.results.is_empty(),
"a non-dismissable modal never pops on a back request"
);
assert_eq!(h.controller.depth(), 2);
}
#[test]
fn a_dismiss_request_pops_with_its_result_only_once_the_ramp_settles() {
let mut h = NavHarness::new();
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_overlay_modal(
&h.controller,
move || {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.dismiss_handle(installed.clone())
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
assert_eq!(h.controller.depth(), 2, "the modal is up");
dismiss.dismiss_with(PopResult::of(7));
h.pass();
h.paint(3000);
assert_eq!(
h.controller.depth(),
2,
"nothing pops during the exit ramp itself"
);
h.drive(3100);
assert_eq!(
h.state.results,
vec![Some(7)],
"the payload rides the staged pop to the pusher's on_result"
);
assert_eq!(h.controller.depth(), 1);
}
#[test]
fn a_request_refused_mid_ramp_never_retargets_the_exit_it_found() {
let mut h = NavHarness::new();
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_overlay_modal(
&h.controller,
move || {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.dismiss_handle(installed.clone())
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
dismiss.dismiss();
h.pass();
h.paint(3000);
dismiss.dismiss_with(PopResult::of(7));
h.drive(3100);
assert_eq!(
h.state.results,
vec![None],
"the ramp in flight is the dismissal, and it keeps its own empty result"
);
assert_eq!(h.controller.depth(), 1);
let re_presented = dismiss.clone();
show_overlay_modal(
&h.controller,
move || {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.dismiss_handle(re_presented.clone())
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(6000);
assert_eq!(h.controller.depth(), 2, "the modal is up again");
dismiss.dismiss();
h.pass();
h.drive(9000);
assert_eq!(
h.state.results,
vec![None, None],
"the re-presented modal observes a fresh request, with no stale payload"
);
assert_eq!(h.controller.depth(), 1);
}
#[test]
fn a_refused_staged_pop_drops_the_payload_it_was_carrying() {
let mut h = NavHarness::new();
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_overlay_modal(
&h.controller,
move || {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.dismiss_handle(installed.clone())
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
dismiss.dismiss_with(PopResult::of(7));
h.pass();
h.paint(3000);
h.controller
.push_transparent(|| core_any::<NavState, _>(BgPage(Size::new(400.0, 100.0))));
h.drive(3100);
assert_eq!(h.controller.depth(), 3, "the pushed page survives");
assert!(h.state.results.is_empty(), "nothing was delivered");
h.controller.request_back();
h.drive(6000);
assert_eq!(h.controller.depth(), 2);
h.controller.request_back();
h.drive(9000);
assert_eq!(
h.state.results,
vec![None],
"a later, unrelated dismissal pops with its own empty result"
);
assert_eq!(h.controller.depth(), 1);
}
#[test]
fn a_plain_dismiss_after_an_unobserved_dismiss_with_pops_empty() {
let mut h = NavHarness::new();
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_overlay_modal(
&h.controller,
move || {
overlay_modal(
Block(Size::new(200.0, 120.0)),
OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
)
.dismiss_handle(installed.clone())
},
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
h.pass();
h.drive(0);
dismiss.dismiss_with(PopResult::of(7));
dismiss.dismiss();
h.pass();
h.drive(3000);
assert_eq!(
h.state.results,
vec![None],
"the plain dismissal pops with nothing, not with the stale payload"
);
assert_eq!(h.controller.depth(), 1);
}
fn sheet_config() -> OverlayModalConfig {
OverlayModalConfig::edge(OverlaySide::Bottom)
.handle(true)
.drag(true)
}
fn dragged() -> (OverlayModalWidget, Rc<Cell<u32>>) {
let (view, closed) = staged(sheet_config());
let mut w = build(&view);
layout(&mut w);
run_ramp(&mut w, 0.0);
(w, closed)
}
#[test]
fn a_drag_past_the_midpoint_closes_and_a_shorter_one_springs_back() {
for (travel, closes) in [(100.0, true), (30.0, false)] {
let (mut w, closed) = dragged();
let extent = w.panel_rect().height();
let start = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + travel),
);
let expected = 1.0 - travel / extent;
assert!(
(w.progress() - expected).abs() < 1e-9,
"{travel} → {}",
w.progress()
);
assert!(
w.panel_rect().y0 > 0.0 && w.panel_rect().y1 > WINDOW.height,
"the panel followed it down"
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, start.x, start.y + travel),
);
run_ramp(&mut w, 3000.0);
if closes {
assert_eq!(closed.get(), 1, "past the midpoint it commits");
assert_eq!(w.progress(), 0.0);
} else {
assert_eq!(closed.get(), 0, "short of it, it settles back open");
assert!((w.progress() - 1.0).abs() < 1e-9);
}
}
}
#[test]
fn a_non_dismissable_sheet_springs_back_from_a_drag_past_the_midpoint() {
let mut w = build(&overlay_modal(
Block(Size::new(200.0, 120.0)),
sheet_config(),
));
layout(&mut w);
run_ramp(&mut w, 0.0);
let extent = w.panel_rect().height();
let start = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + extent * 0.9),
);
assert!(
w.progress() < 0.2,
"the panel still tracks the pointer while dragging: {}",
w.progress()
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, start.x, start.y + extent * 0.9),
);
run_ramp(&mut w, 3000.0);
assert!(
(w.progress() - 1.0).abs() < 1e-9,
"no dismiss hook: springs back to open_progress() instead of \
sticking near zero as an invisible full-window barrier — {}",
w.progress()
);
assert!(w.panel_rect().contains(w.panel_rect().center()));
assert!(w.panel_rect().y0 < WINDOW.height && w.panel_rect().y1 == WINDOW.height);
assert_eq!(state.dismissed, 0, "no dismiss hook ever fired");
}
#[test]
fn snap_points_zero_is_treated_as_absent_and_opens_fully() {
let (view, _closed) = staged(sheet_config());
let mut w = build(&view.snap_points(&[0.0]));
assert!(
w.open_progress() > PROGRESS_EPSILON,
"a first point of 0.0 is treated as absent: {}",
w.open_progress()
);
assert_eq!(w.open_progress(), 1.0, "falls back to fully open");
layout(&mut w);
run_ramp(&mut w, 0.0);
assert!(
(w.progress() - 1.0).abs() < 1e-9,
"the sheet opens fully rather than opening already closed: {}",
w.progress()
);
}
#[test]
fn a_flick_toward_the_edge_closes_from_anywhere() {
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let mut w = build(
&overlay_modal(Block(Size::new(200.0, 400.0)), sheet_config())
.on_close(move || hook.set(hook.get() + 1)),
);
layout(&mut w);
run_ramp(&mut w, 0.0);
let start = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
frame(&mut w, 3000.0);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + 40.0),
);
frame(&mut w, 3016.0);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + 80.0),
);
assert!(w.progress() > 0.5, "still mostly open: {}", w.progress());
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, start.x, start.y + 80.0),
);
run_ramp(&mut w, 4000.0);
assert_eq!(closed.get(), 1, "the flick committed to the close");
}
#[test]
fn catching_a_closing_sheet_cancels_its_dismissal() {
let (mut w, closed) = dragged();
let mut state = Flags::default();
let handle = w.handle_rect().unwrap().center();
dispatch(&mut w, &mut state, &escape());
frame(&mut w, 3000.0);
frame(
&mut w,
3000.0 + MaterialMotion::LONG_2.as_millis() as f64 / 4.0,
);
assert!(w.is_exiting() && w.progress() < 1.0);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, handle.x, handle.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, handle.x, handle.y - 60.0),
);
assert!(!w.is_exiting(), "the drag owns the panel now");
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, handle.x, handle.y - 60.0),
);
run_ramp(&mut w, 4000.0);
assert_eq!(closed.get(), 0, "nothing closed");
assert!((w.progress() - 1.0).abs() < 1e-9, "back open");
}
#[test]
fn a_cancelled_drag_returns_to_where_it_started() {
let (mut w, closed) = dragged();
let start = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + 90.0),
);
assert!(w.progress() < 1.0);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Cancel, start.x, start.y + 90.0),
);
run_ramp(&mut w, 3000.0);
assert!((w.progress() - 1.0).abs() < 1e-9, "restored");
assert_eq!(closed.get(), 0);
}
#[test]
fn a_tap_on_a_draggable_handle_still_dismisses_but_one_on_the_panel_does_not() {
let (mut w, closed) = dragged();
let mut state = Flags::default();
let handle = w.handle_rect().unwrap().center();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, handle.x, handle.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, handle.x, handle.y),
);
run_ramp(&mut w, 3000.0);
assert_eq!(closed.get(), 1);
let (mut w, closed) = dragged();
let body = w.panel_rect().center();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, body.x, body.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, body.x, body.y),
);
run_ramp(&mut w, 3000.0);
assert_eq!(
closed.get(),
0,
"the panel surface is a barrier, not a button"
);
assert!((w.progress() - 1.0).abs() < 1e-9);
}
#[test]
fn snap_points_open_at_the_first_and_the_release_takes_the_nearest() {
let (view, closed) = staged(sheet_config());
let mut w = build(&view.snap_points(&[0.5, 1.0]));
layout(&mut w);
run_ramp(&mut w, 0.0);
assert!(
(w.progress() - 0.5).abs() < 1e-9,
"opens at the first point: {}",
w.progress()
);
let extent = w.panel_rect().height();
assert!(
(w.panel_rect().y0 - (WINDOW.height - extent * 0.5)).abs() < 1e-9,
"and shows exactly that fraction of itself"
);
let start = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y - extent * 0.4),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, start.x, start.y - extent * 0.4),
);
run_ramp(&mut w, 3000.0);
assert!((w.progress() - 1.0).abs() < 1e-9, "{}", w.progress());
assert_eq!(closed.get(), 0);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + extent * 0.85),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, start.x, start.y + extent * 0.85),
);
run_ramp(&mut w, 6000.0);
assert_eq!(closed.get(), 1);
}
#[test]
fn a_snap_point_above_one_is_logical_px_against_the_extent() {
let (view, _closed) = staged(sheet_config());
let mut w = build(&view.snap_points(&[60.0]));
layout(&mut w);
run_ramp(&mut w, 0.0);
let extent = w.panel_rect().height();
assert!(
(w.progress() - 60.0 / extent).abs() < 1e-9,
"{}",
w.progress()
);
assert!(
(w.panel_rect().y0 - (WINDOW.height - 60.0)).abs() < 1e-9,
"60 logical px of the sheet are on screen"
);
}
#[test]
fn snap_points_drops_non_finite_values_at_the_boundary() {
let (view, _closed) = staged(sheet_config());
let view = view.snap_points(&[f64::NAN, f64::INFINITY]);
assert!(
view.snap_points.is_empty(),
"NaN and infinity are both non-finite: dropped by the builder, \
never stored"
);
}
#[test]
fn non_finite_snap_points_behave_as_absent_through_a_drag_and_never_panic() {
let (view, closed) = staged(sheet_config());
let mut w = build(&view.snap_points(&[f64::NAN, f64::INFINITY]));
layout(&mut w);
run_ramp(&mut w, 0.0);
assert!(
(w.progress() - 1.0).abs() < 1e-9,
"opens fully, same as no snap points at all: {}",
w.progress()
);
let extent = w.panel_rect().height();
let start = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, start.x, start.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, start.x, start.y + extent * 0.9),
);
assert!(
w.progress().is_finite(),
"no NaN reached progress mid-drag: {}",
w.progress()
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, start.x, start.y + extent * 0.9),
);
run_ramp(&mut w, 3000.0);
assert!(w.progress().is_finite());
assert_eq!(
closed.get(),
1,
"past the midpoint it commits, same as the no-snap-points default"
);
}
#[test]
fn degenerate_snap_points_dont_strand_the_barrier_on_a_tap_dismiss() {
let (view, closed) = staged(sheet_config());
let mut w = build(&view.snap_points(&[f64::NAN, f64::INFINITY]));
layout(&mut w);
run_ramp(&mut w, 0.0);
let handle = w.handle_rect().unwrap().center();
let mut state = Flags::default();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, handle.x, handle.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, handle.x, handle.y),
);
run_ramp(&mut w, 3000.0);
assert!(
w.progress().is_finite(),
"no NaN reached progress: {}",
w.progress()
);
assert_eq!(w.progress(), 0.0, "settled fully closed, not stranded");
assert_eq!(
closed.get(),
1,
"the barrier settled and fired on_close, not left stuck open"
);
}
#[test]
fn semantics_is_a_modal_node_labelled_by_its_title() {
fn logic(_s: &mut Flags) -> OverlayModalView<Flags> {
overlay_modal(
Block(Size::new(100.0, 50.0)),
settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
.role(OverlayRole::AlertDialog),
)
.label("Are you sure?")
}
let mut root: RenderRoot<Flags, OverlayModalView<Flags>> = RenderRoot::new();
let mut state = Flags::default();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::AlertDialog)
.expect("an AlertDialog node");
assert!(node.is_modal());
assert_eq!(node.label(), Some("Are you sure?"));
}
#[test]
fn visit_children_publishes_the_content() {
let w = build(&view(settled(OverlayModalConfig::centered(
OVERLAY_DIALOG_MAX_WIDTH,
))));
let mut seen = 0usize;
Widget::visit_children(&w, &mut |_pod| seen += 1);
assert_eq!(seen, 1);
}
}