use std::rc::Rc;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust::input::{TOUCH_SLOP, VelocityTracker};
use frust::{AnimationController, FrameTime, IconData, IconSource, SpringDesc, Theme};
use kurbo::{Affine, BezPath, Point, Size, Vec2};
use peniko::{Brush, Color};
use crate::interaction::{HapticSignal, MaterialHaptics};
use crate::press::presses;
pub const DISMISS_THRESHOLD: f64 = 0.2;
pub const DISMISS_ACTION_GAP: f64 = 8.0;
pub const DISMISS_BACKGROUND_RADIUS: f64 = 100.0;
pub const DISMISS_FLY_OVERSHOOT: f64 = 80.0;
pub const DISMISS_COLLAPSE_HANDOFF: f64 = 0.9;
pub const DISMISS_FLING_VELOCITY: f64 = 400.0;
pub const DISMISS_ICON_SIZE: f64 = 24.0;
const ICON_FADE_START: f64 = 0.3;
const ICON_MIN_SCALE: f64 = 0.8;
const BAND_FADE_GAIN: f64 = 3.0;
const ERROR_CONTAINER: Color = Color::from_rgb8(0xF9, 0xDE, 0xDC);
const ON_ERROR_CONTAINER: Color = Color::from_rgb8(0x41, 0x0E, 0x0B);
const SLIDE_STIFFNESS: f64 = 400.0;
const SLIDE_DAMPING: f64 = 0.8;
const SPRING_BACK_STIFFNESS: f64 = 380.0;
const SPRING_BACK_DAMPING: f64 = 0.6;
const COLLAPSE_STIFFNESS: f64 = 50.0;
const COLLAPSE_DAMPING: f64 = 0.8;
const EXIT_PERIOD: std::time::Duration = std::time::Duration::from_millis(300);
const EXIT_LAUNCH_VELOCITY: f64 = 1e-3;
pub fn speed_multiplier(velocity: f64) -> f64 {
if !velocity.is_finite() {
return 1.0;
}
(1.0 + velocity.abs() / 1000.0).clamp(1.0, 4.0)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DismissDirection {
StartToEnd,
EndToStart,
}
impl DismissDirection {
fn of(offset: f64) -> Option<Self> {
if offset > 0.0 {
Some(Self::StartToEnd)
} else if offset < 0.0 {
Some(Self::EndToStart)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct DismissBackground {
color: Option<Color>,
icon: Option<IconSource>,
icon_color: Option<Color>,
radius: Option<f64>,
}
pub fn dismiss_background() -> DismissBackground {
DismissBackground::default()
}
impl DismissBackground {
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn icon(mut self, icon: IconSource) -> Self {
self.icon = Some(icon);
self
}
pub fn icon_color(mut self, color: Color) -> Self {
self.icon_color = Some(color);
self
}
pub fn radius(mut self, radius: f64) -> Self {
self.radius = Some(radius);
self
}
}
fn same_icon(a: Option<IconSource>, b: Option<IconSource>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a.d == b.d && a.design == b.design,
_ => false,
}
}
fn icon_geometry(background: Option<&DismissBackground>) -> Option<(BezPath, f64)> {
background
.and_then(|b| b.icon)
.map(|source| IconData::from(source).resolve())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Motion {
Rest,
SpringBack,
Exit,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Gesture {
None,
Pending { start: Point },
Dragging { start_x: f64 },
Declined,
}
type OnDismissed<State> = Rc<dyn Fn(&mut State, DismissDirection)>;
pub struct DismissibleView<State: 'static> {
child: AnyView<State>,
background: Option<DismissBackground>,
secondary_background: Option<DismissBackground>,
threshold: f64,
action_gap: f64,
only: Option<DismissDirection>,
enabled: bool,
haptic: HapticSignal,
on_dismissed: Option<OnDismissed<State>>,
}
pub fn dismissible<State: 'static, V: View<State>>(child: V) -> DismissibleView<State> {
DismissibleView {
child: any(child),
background: None,
secondary_background: None,
threshold: DISMISS_THRESHOLD,
action_gap: DISMISS_ACTION_GAP,
only: None,
enabled: true,
haptic: HapticSignal::None,
on_dismissed: None,
}
}
impl<State: 'static> DismissibleView<State> {
pub fn background(mut self, background: DismissBackground) -> Self {
self.background = Some(background);
self
}
pub fn secondary_background(mut self, background: DismissBackground) -> Self {
self.secondary_background = Some(background);
self
}
pub fn threshold(mut self, threshold: f64) -> Self {
self.threshold = threshold;
self
}
pub fn action_gap(mut self, gap: f64) -> Self {
self.action_gap = gap;
self
}
pub fn only(mut self, direction: DismissDirection) -> Self {
self.only = Some(direction);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn on_dismissed<F: Fn(&mut State, DismissDirection) + 'static>(
mut self,
on_dismissed: F,
) -> Self {
self.on_dismissed = Some(Rc::new(on_dismissed));
self
}
}
pub struct DismissibleWidget {
child: ChildPod,
background: Option<DismissBackground>,
secondary_background: Option<DismissBackground>,
background_icon: Option<(BezPath, f64)>,
secondary_icon: Option<(BezPath, f64)>,
threshold: f64,
action_gap: f64,
only: Option<DismissDirection>,
enabled: bool,
haptic: HapticSignal,
on_dismissed: Option<ErasedArgCallback<DismissDirection>>,
width: f64,
child_height: f64,
offset: f64,
collapse: f64,
exit_direction: Option<DismissDirection>,
gesture: Gesture,
motion: Motion,
tracker: VelocityTracker,
leg_from: f64,
leg_to: f64,
ramp: AnimationController,
collapse_ramp: AnimationController,
collapse_started: bool,
collapse_multiplier: f64,
last_frame_time: FrameTime,
}
fn scaled_spring(base: f64, damping_ratio: f64, multiplier: f64) -> SpringDesc {
SpringDesc {
mass: 1.0,
stiffness: base * multiplier,
damping_ratio,
}
}
fn resolve_band(theme: Option<&Theme>, background: &DismissBackground) -> Color {
if let Some(color) = background.color {
return color;
}
match theme {
Some(theme) => theme.scheme().error_container,
None => ERROR_CONTAINER,
}
}
fn resolve_band_icon(theme: Option<&Theme>, background: &DismissBackground) -> Color {
if let Some(color) = background.icon_color {
return color;
}
match theme {
Some(theme) => theme.scheme().on_error_container,
None => ON_ERROR_CONTAINER,
}
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
impl<State: 'static> View<State> for DismissibleView<State> {
type Element = DismissibleWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> DismissibleWidget {
DismissibleWidget {
child: frust::authoring::build_child(&self.child, ctx),
background: self.background,
secondary_background: self.secondary_background,
background_icon: icon_geometry(self.background.as_ref()),
secondary_icon: icon_geometry(self.secondary_background.as_ref()),
threshold: self.threshold,
action_gap: self.action_gap,
only: self.only,
enabled: self.enabled,
haptic: self.haptic,
on_dismissed: self
.on_dismissed
.as_ref()
.map(frust::authoring::erase_callback_arg),
width: 0.0,
child_height: 0.0,
offset: 0.0,
collapse: 0.0,
exit_direction: None,
gesture: Gesture::None,
motion: Motion::Rest,
tracker: VelocityTracker::new(),
leg_from: 0.0,
leg_to: 0.0,
ramp: AnimationController::new(EXIT_PERIOD),
collapse_ramp: AnimationController::new(EXIT_PERIOD),
collapse_started: false,
collapse_multiplier: 1.0,
last_frame_time: FrameTime::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut DismissibleWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags =
frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
if !same_icon(
element.background.and_then(|b| b.icon),
self.background.and_then(|b| b.icon),
) {
element.background_icon = icon_geometry(self.background.as_ref());
flags |= ChangeFlags::PAINT;
}
if !same_icon(
element.secondary_background.and_then(|b| b.icon),
self.secondary_background.and_then(|b| b.icon),
) {
element.secondary_icon = icon_geometry(self.secondary_background.as_ref());
flags |= ChangeFlags::PAINT;
}
element.background = self.background;
element.secondary_background = self.secondary_background;
element.threshold = self.threshold;
element.action_gap = self.action_gap;
element.only = self.only;
element.haptic = self.haptic;
element.on_dismissed = self
.on_dismissed
.as_ref()
.map(frust::authoring::erase_callback_arg);
if element.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled {
element.gesture = Gesture::None;
element.motion = Motion::Rest;
element.offset = 0.0;
element.exit_direction = None;
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
fn teardown(&self, element: &mut DismissibleWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.child, &mut element.child, ctx);
}
}
impl DismissibleWidget {
fn progress_at(&self, offset: f64) -> f64 {
let denominator = self.width * self.threshold;
if !denominator.is_finite() || denominator <= 0.0 || !offset.is_finite() {
return 0.0;
}
(offset.abs() / denominator).clamp(0.0, 1.0)
}
fn allows(&self, dx: f64) -> bool {
match (self.only, DismissDirection::of(dx)) {
(_, None) => false,
(None, Some(_)) => true,
(Some(allowed), Some(actual)) => allowed == actual,
}
}
fn band(
&self,
direction: DismissDirection,
) -> Option<(&DismissBackground, Option<&(BezPath, f64)>)> {
match direction {
DismissDirection::StartToEnd => self
.background
.as_ref()
.map(|b| (b, self.background_icon.as_ref())),
DismissDirection::EndToStart => match self.secondary_background.as_ref() {
Some(b) => Some((b, self.secondary_icon.as_ref())),
None => self
.background
.as_ref()
.map(|b| (b, self.background_icon.as_ref())),
},
}
}
fn event_time_ms(&self) -> f64 {
self.last_frame_time.as_secs_f64() * 1000.0
}
fn cancel_child(&mut self, ctx: &mut EventCtx, position: Point) {
let cancel = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Cancel,
position,
button: PointerButton::Primary,
});
self.child.event_child(ctx, &cancel);
self.child.set_active(false);
}
fn start_spring_back(&mut self, multiplier: f64) {
self.leg_from = self.offset;
self.leg_to = 0.0;
self.motion = Motion::SpringBack;
self.ramp = AnimationController::new(EXIT_PERIOD);
self.ramp.fling(
EXIT_LAUNCH_VELOCITY,
scaled_spring(SPRING_BACK_STIFFNESS, SPRING_BACK_DAMPING, multiplier),
);
}
fn start_exit(&mut self, direction: DismissDirection, multiplier: f64) {
let sign = match direction {
DismissDirection::StartToEnd => 1.0,
DismissDirection::EndToStart => -1.0,
};
self.exit_direction = Some(direction);
self.leg_from = self.offset;
self.leg_to = sign * (self.width + DISMISS_FLY_OVERSHOOT);
self.motion = Motion::Exit;
self.collapse_started = false;
self.collapse = 0.0;
self.ramp = AnimationController::new(EXIT_PERIOD);
self.ramp.fling(
EXIT_LAUNCH_VELOCITY,
scaled_spring(SLIDE_STIFFNESS, SLIDE_DAMPING, multiplier),
);
self.collapse_ramp = AnimationController::new(EXIT_PERIOD);
self.collapse_multiplier = multiplier;
}
fn advance(&mut self, now: FrameTime) -> (bool, bool) {
match self.motion {
Motion::Rest => (false, false),
Motion::SpringBack => {
let animating = self.ramp.advance(now);
let t = self.ramp.value_clamped();
self.offset = self.leg_from + (self.leg_to - self.leg_from) * t;
if !animating {
self.offset = self.leg_to;
self.motion = Motion::Rest;
}
(animating, false)
}
Motion::Exit => {
let sliding = self.ramp.advance(now);
let t = self.ramp.value_clamped();
self.offset = self.leg_from + (self.leg_to - self.leg_from) * t;
if !self.collapse_started && (t >= DISMISS_COLLAPSE_HANDOFF || !sliding) {
self.collapse_started = true;
self.collapse_ramp.fling(
EXIT_LAUNCH_VELOCITY,
scaled_spring(
COLLAPSE_STIFFNESS,
COLLAPSE_DAMPING,
self.collapse_multiplier,
),
);
}
let collapsing = self.collapse_started && self.collapse_ramp.advance(now);
if self.collapse_started {
self.collapse = self.collapse_ramp.value_clamped();
}
if !sliding && !collapsing && self.collapse_started {
self.collapse = 1.0;
self.motion = Motion::Rest;
}
(sliding || collapsing, self.collapse_started)
}
}
}
fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
if event.is_broadcast() || !self.enabled || self.motion == Motion::Exit {
return frust::authoring::route_event_single(&mut self.child, ctx, event);
}
let InputEvent::Pointer(p) = event else {
return frust::authoring::route_event_single(&mut self.child, ctx, event);
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return frust::authoring::route_event_single(&mut self.child, ctx, event);
}
self.motion = Motion::Rest;
self.offset = 0.0;
self.gesture = Gesture::Pending { start: p.position };
self.tracker.clear();
self.tracker.record(t_ms, p.position.x);
ctx.capture_pointer();
frust::authoring::route_event_single(&mut self.child, ctx, event);
EventResult::Handled
}
PointerPhase::Move => match self.gesture {
Gesture::None | Gesture::Declined => {
frust::authoring::route_event_single(&mut self.child, ctx, event)
}
Gesture::Pending { start } => {
self.tracker.record(t_ms, p.position.x);
let dx = p.position.x - start.x;
let dy = p.position.y - start.y;
if dy.abs() > TOUCH_SLOP && dy.abs() >= dx.abs() {
self.gesture = Gesture::Declined;
return frust::authoring::route_event_single(&mut self.child, ctx, event);
}
if dx.abs() > TOUCH_SLOP && dx.abs() > dy.abs() && self.allows(dx) {
self.gesture = Gesture::Dragging { start_x: start.x };
self.cancel_child(ctx, p.position);
self.offset = dx;
ctx.request_redraw();
return EventResult::Handled;
}
frust::authoring::route_event_single(&mut self.child, ctx, event)
}
Gesture::Dragging { start_x } => {
self.tracker.record(t_ms, p.position.x);
let dx = p.position.x - start_x;
let clamped = match self.only {
None => dx,
Some(DismissDirection::StartToEnd) => dx.max(0.0),
Some(DismissDirection::EndToStart) => dx.min(0.0),
};
if clamped != self.offset {
self.offset = clamped;
ctx.request_redraw();
}
EventResult::Handled
}
},
PointerPhase::Up => {
let Gesture::Dragging { .. } = self.gesture else {
self.gesture = Gesture::None;
return frust::authoring::route_event_single(&mut self.child, ctx, event);
};
self.gesture = Gesture::None;
self.tracker.record(t_ms, p.position.x);
let velocity = self.tracker.velocity();
let multiplier = speed_multiplier(velocity);
let past_threshold = self.progress_at(self.offset) >= 1.0;
let flung = velocity.abs() >= DISMISS_FLING_VELOCITY;
let direction = if flung {
DismissDirection::of(velocity)
} else {
DismissDirection::of(self.offset)
};
match direction.filter(|_| past_threshold || flung) {
Some(direction)
if self.only.is_none_or(|allowed| allowed == direction)
&& self.on_dismissed.is_some() =>
{
if self.haptic != HapticSignal::None {
MaterialHaptics::fire(self.haptic);
}
if let Some(on_dismissed) = self.on_dismissed.as_mut() {
(on_dismissed)(ctx, direction);
}
self.start_exit(direction, multiplier);
}
_ => self.start_spring_back(multiplier),
}
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
let resolved = self.gesture;
self.gesture = Gesture::None;
match resolved {
Gesture::Dragging { .. } => {
self.start_spring_back(1.0);
ctx.request_redraw();
EventResult::Handled
}
_ => frust::authoring::route_event_single(&mut self.child, ctx, event),
}
}
}
}
fn paint_band(
&self,
scene: &mut dyn PaintScene,
theme: Option<&Theme>,
origin: Point,
height: f64,
direction: DismissDirection,
) {
let Some((background, icon)) = self.band(direction) else {
return;
};
let revealed = self.offset.abs();
let band_width = (revealed - self.action_gap).max(0.0);
if band_width <= 0.0 || height <= 0.0 {
return;
}
let progress = self.progress_at(self.offset);
let band_alpha = (progress * BAND_FADE_GAIN).clamp(0.0, 1.0) as f32;
if band_alpha <= 0.0 {
return;
}
let band_x = match direction {
DismissDirection::StartToEnd => origin.x,
DismissDirection::EndToStart => origin.x + self.width - band_width,
};
let band_origin = Point::new(band_x, origin.y);
let radius = background.radius.unwrap_or(DISMISS_BACKGROUND_RADIUS);
scene.fill_rounded_rect(
band_origin,
Size::new(band_width, height),
radius,
with_alpha(resolve_band(theme, background), band_alpha),
);
let Some((path, design)) = icon else {
return;
};
if *design <= 0.0 {
return;
}
let ramp = ((progress - ICON_FADE_START) / (1.0 - ICON_FADE_START)).clamp(0.0, 1.0);
let icon_alpha = (ramp * band_alpha as f64) as f32;
if icon_alpha <= 0.0 {
return;
}
let icon_scale = ICON_MIN_SCALE + (1.0 - ICON_MIN_SCALE) * ramp;
let extent = DISMISS_ICON_SIZE * icon_scale;
let center = Point::new(
band_origin.x + band_width / 2.0,
band_origin.y + height / 2.0,
);
let transform = Affine::translate(Vec2::new(center.x, center.y))
* Affine::scale(extent / design)
* Affine::translate(Vec2::new(-design / 2.0, -design / 2.0));
scene.fill_path(
Point::ZERO,
&(transform * path.clone()),
&Brush::Solid(with_alpha(resolve_band_icon(theme, background), icon_alpha)),
);
}
}
impl Widget for DismissibleWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
self.width = width;
let child_bc =
BoxConstraints::new(Size::new(width, 0.0), Size::new(width, bc.max().height));
let child = self.child.layout_child(ctx, &child_bc);
self.child_height = child.height;
self.child.set_origin(Point::ORIGIN);
let height = (child.height * (1.0 - self.collapse)).max(0.0);
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
self.last_frame_time = ctx.frame_time();
let (needs_frame, needs_layout) = self.advance(ctx.frame_time());
let origin = ctx.origin();
let height = (self.child_height * (1.0 - self.collapse)).max(0.0);
if height <= 0.0 {
if needs_layout {
ctx.request_layout();
} else if needs_frame {
ctx.request_frame();
}
return;
}
if let Some(direction) = self
.exit_direction
.filter(|_| self.motion == Motion::Exit)
.or_else(|| DismissDirection::of(self.offset))
{
self.paint_band(scene, theme, origin, height, direction);
}
scene.push_clip(origin, Size::new(self.width, height));
scene.push_transform(Affine::translate(Vec2::new(self.offset, 0.0)));
self.child.paint_child(ctx, scene);
scene.pop_transform();
scene.pop_clip();
if needs_layout {
ctx.request_layout();
} else if needs_frame {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let t_ms = self.event_time_ms();
self.event_at(ctx, event, t_ms)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics_child(ctx);
}
frust::authoring::visit_children!(child);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use std::any::Any;
const WIDTH: f64 = 300.0;
const ROW_HEIGHT: f64 = 60.0;
const COMMIT_PX: f64 = WIDTH * DISMISS_THRESHOLD;
fn build<S: 'static>(view: &DismissibleView<S>) -> DismissibleWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut DismissibleWidget) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(
&mut lctx,
&BoxConstraints::new(Size::new(WIDTH, 0.0), Size::new(WIDTH, f64::INFINITY)),
)
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
clips: Vec<(Point, Size)>,
transforms: Vec<Affine>,
paths: Vec<BezPath>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn push_clip(&mut self, origin: Point, size: Size) {
self.clips.push((origin, size));
}
fn pop_clip(&mut self) {}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {}
fn fill_path(&mut self, _origin: Point, path: &BezPath, _brush: &Brush) {
self.paths.push(path.clone());
}
}
fn paint_at(
w: &mut DismissibleWidget,
t_ms: u64,
theme: Option<&Theme>,
) -> (Recorder, bool, bool) {
let mut rec = Recorder::default();
let mut ctx = PaintCtx::for_test(
Point::ZERO,
Size::new(WIDTH, ROW_HEIGHT),
FrameTime::from_nanos(t_ms * 1_000_000),
);
if let Some(theme) = theme {
ctx = ctx.with_theme(theme);
}
w.paint(&mut ctx, &mut rec);
(rec, ctx.needs_frame(), ctx.needs_layout())
}
fn at(
w: &mut DismissibleWidget,
state: &mut dyn Any,
phase: PointerPhase,
pos: Point,
t_ms: f64,
) -> EventResult {
let event = InputEvent::Pointer(PointerEvent {
phase,
position: pos,
button: PointerButton::Primary,
});
let mut ctx = EventCtx::new(state, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT));
w.event_at(&mut ctx, &event, t_ms)
}
fn slow_drag(w: &mut DismissibleWidget, state: &mut dyn Any, dx: f64) -> f64 {
let mut t = 0.0;
at(w, state, PointerPhase::Down, Point::new(150.0, 30.0), t);
let steps = 6;
for step in 1..=steps {
t += 100.0;
let x = 150.0 + dx * (step as f64 / steps as f64);
at(w, state, PointerPhase::Move, Point::new(x, 30.0), t);
}
t
}
fn view() -> DismissibleView<u32> {
dismissible(frust::SizedBox::<u32>(Some(WIDTH), Some(ROW_HEIGHT)))
}
fn recording() -> DismissibleView<Vec<u32>> {
dismissible(frust::SizedBox::<Vec<u32>>(Some(WIDTH), Some(ROW_HEIGHT))).on_dismissed(
|s: &mut Vec<u32>, direction| {
s.push(match direction {
DismissDirection::StartToEnd => 1,
DismissDirection::EndToStart => 2,
})
},
)
}
#[test]
fn progress_is_the_offset_over_the_commit_distance() {
let mut w = build(&view());
layout(&mut w);
assert_eq!(w.progress_at(0.0), 0.0);
assert!((w.progress_at(COMMIT_PX / 2.0) - 0.5).abs() < 1e-9);
assert_eq!(w.progress_at(COMMIT_PX), 1.0);
assert_eq!(
w.progress_at(-COMMIT_PX),
1.0,
"symmetric in both directions"
);
assert_eq!(w.progress_at(WIDTH * 10.0), 1.0, "clamped at 1");
}
#[test]
fn progress_is_zero_before_the_first_layout_rather_than_dividing_by_zero() {
let w = build(&view());
assert_eq!(w.width, 0.0);
assert_eq!(w.progress_at(500.0), 0.0);
}
#[test]
fn a_non_positive_threshold_leaves_the_position_arm_unreachable() {
let mut w = build(&view().threshold(0.0));
layout(&mut w);
assert_eq!(w.progress_at(WIDTH), 0.0);
}
#[test]
fn a_slow_release_past_the_threshold_commits_rightward() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, COMMIT_PX + 10.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + COMMIT_PX + 10.0, 30.0),
t,
);
assert_eq!(state, vec![1], "start→end reported once");
assert_eq!(w.motion, Motion::Exit);
assert_eq!(w.exit_direction, Some(DismissDirection::StartToEnd));
}
#[test]
fn a_slow_release_past_the_threshold_commits_leftward() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, -(COMMIT_PX + 10.0));
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 - COMMIT_PX - 10.0, 30.0),
t,
);
assert_eq!(state, vec![2], "end→start reported once");
assert_eq!(w.exit_direction, Some(DismissDirection::EndToStart));
}
#[test]
fn a_press_during_a_spring_back_stops_it_and_reseats_the_row() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let short = COMMIT_PX / 2.0;
let t = slow_drag(&mut w, &mut state, short);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + short, 30.0),
t,
);
paint_at(&mut w, 0, None);
paint_at(&mut w, 16, None);
assert_eq!(w.motion, Motion::SpringBack);
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
t + 32.0,
);
assert_eq!(w.motion, Motion::Rest, "the spring-back is stopped");
assert_eq!(w.offset, 0.0, "and the row is reseated, never left stale");
assert!(matches!(w.gesture, Gesture::Pending { .. }));
}
#[test]
fn a_slow_release_short_of_the_threshold_springs_back() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let short = COMMIT_PX / 2.0;
let t = slow_drag(&mut w, &mut state, short);
assert!(w.offset > 0.0, "the row followed the finger");
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + short, 30.0),
t,
);
assert!(state.is_empty(), "below the commit line: nothing fired");
assert_eq!(w.motion, Motion::SpringBack);
let mut t_ms = 0u64;
loop {
let (_, needs_frame, _) = paint_at(&mut w, t_ms, None);
if !needs_frame {
break;
}
t_ms += 16;
assert!(t_ms < 10_000, "the spring-back should settle inside 10s");
}
assert_eq!(w.offset, 0.0);
assert_eq!(w.motion, Motion::Rest);
assert_eq!(w.collapse, 0.0, "a spring-back never collapses the row");
}
#[test]
fn a_fast_flick_short_of_the_threshold_commits_both_directions() {
for (dx, expected, direction) in [
(20.0, 1u32, DismissDirection::StartToEnd),
(-20.0, 2u32, DismissDirection::EndToStart),
] {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
0.0,
);
at(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(150.0 + dx, 30.0),
20.0,
);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + dx, 30.0),
20.0,
);
assert!(
w.progress_at(w.offset) < 1.0,
"the drag stayed short of the threshold"
);
assert_eq!(state, vec![expected]);
assert_eq!(w.exit_direction, Some(direction));
}
}
#[test]
fn the_speed_multiplier_matches_the_reference_ramp() {
assert_eq!(speed_multiplier(0.0), 1.0);
assert_eq!(speed_multiplier(1000.0), 2.0);
assert_eq!(speed_multiplier(-1000.0), 2.0, "sign-independent");
assert_eq!(speed_multiplier(10_000.0), 4.0, "clamped at 4");
assert_eq!(speed_multiplier(f64::NAN), 1.0, "never a NaN stiffness");
}
#[test]
fn a_vertical_drag_never_resolves_into_a_horizontal_dismiss() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
0.0,
);
for (step, t) in [(1.0, 100.0), (2.0, 200.0), (3.0, 300.0)] {
at(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(150.0, 30.0 + step * TOUCH_SLOP),
t,
);
}
assert_eq!(w.gesture, Gesture::Declined);
assert_eq!(w.offset, 0.0, "a vertical drag never displaces the row");
at(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(150.0 + WIDTH, 30.0 + 3.0 * TOUCH_SLOP),
400.0,
);
assert_eq!(w.gesture, Gesture::Declined);
assert_eq!(w.offset, 0.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + WIDTH, 30.0 + 3.0 * TOUCH_SLOP),
420.0,
);
assert!(state.is_empty(), "a vertical gesture dismisses nothing");
assert_eq!(w.motion, Motion::Rest, "and starts no exit or spring-back");
}
#[test]
fn inside_a_scroll_view_a_vertical_drag_scrolls_and_a_horizontal_one_dismisses() {
#[derive(Default)]
struct S {
dismissed: Vec<usize>,
offset: f64,
}
fn logic(_s: &mut S) -> AnyView<S> {
let rows: Vec<AnyView<S>> = (0..8)
.map(|i| {
any(
dismissible(frust::SizedBox::<S>(Some(WIDTH), Some(ROW_HEIGHT)))
.on_dismissed(move |s: &mut S, _| s.dismissed.push(i)),
)
})
.collect();
any(frust::scroll_view(frust::Column(rows))
.on_scroll(|s: &mut S, info| s.offset = info.offset))
}
let ev = |phase, x: f64, y: f64| {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
};
let boot = || {
let mut root: frust_core::RenderRoot<S, AnyView<S>> = frust_core::RenderRoot::new();
let mut state = S::default();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(WIDTH, 200.0), &mut tcx as &mut dyn Any);
(root, state)
};
let (mut root, mut state) = boot();
root.event(&mut state, &ev(PointerPhase::Down, 150.0, 30.0));
for step in [3.0, 6.0] {
root.event(
&mut state,
&ev(PointerPhase::Move, 150.0, 30.0 - step * TOUCH_SLOP),
);
}
root.event(
&mut state,
&ev(PointerPhase::Up, 150.0, 30.0 - 6.0 * TOUCH_SLOP),
);
assert!(
state.dismissed.is_empty(),
"a vertical scroll gesture dismissed a row"
);
assert!(
state.offset > 0.0,
"the scroll surface should have taken the gesture, offset {}",
state.offset
);
let (mut root, mut state) = boot();
root.event(&mut state, &ev(PointerPhase::Down, 150.0, 30.0));
root.event(
&mut state,
&ev(PointerPhase::Move, 150.0 + 3.0 * TOUCH_SLOP, 30.0),
);
root.event(
&mut state,
&ev(PointerPhase::Move, 150.0 + COMMIT_PX + 10.0, 30.0),
);
root.event(
&mut state,
&ev(PointerPhase::Up, 150.0 + COMMIT_PX + 10.0, 30.0),
);
assert_eq!(state.dismissed, vec![0]);
assert_eq!(state.offset, 0.0, "a horizontal dismiss never scrolls");
}
#[test]
fn a_move_inside_the_slop_resolves_neither_axis() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
0.0,
);
at(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(150.0 + TOUCH_SLOP / 2.0, 30.0),
50.0,
);
assert!(matches!(w.gesture, Gesture::Pending { .. }));
assert_eq!(w.offset, 0.0);
}
#[test]
fn a_diagonal_drag_resolves_to_whichever_axis_dominates() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
0.0,
);
at(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(150.0 + 3.0 * TOUCH_SLOP, 30.0 + TOUCH_SLOP),
100.0,
);
assert!(matches!(w.gesture, Gesture::Dragging { .. }));
let mut w = build(&recording());
layout(&mut w);
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
0.0,
);
at(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(150.0 + TOUCH_SLOP, 30.0 + 3.0 * TOUCH_SLOP),
100.0,
);
assert_eq!(w.gesture, Gesture::Declined);
}
#[test]
fn a_disallowed_direction_never_resolves_horizontally() {
let mut w = build(&recording().only(DismissDirection::EndToStart));
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, COMMIT_PX * 2.0);
assert!(
matches!(w.gesture, Gesture::Pending { .. }),
"a rightward drag stays unresolved on an end→start-only row"
);
assert_eq!(w.offset, 0.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + COMMIT_PX * 2.0, 30.0),
t,
);
assert!(state.is_empty());
}
#[test]
fn a_disabled_row_forwards_every_pass_untouched() {
let mut w = build(&recording().enabled(false));
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, COMMIT_PX * 2.0);
assert_eq!(w.gesture, Gesture::None);
assert_eq!(w.offset, 0.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + COMMIT_PX * 2.0, 30.0),
t,
);
assert!(state.is_empty());
}
#[test]
fn a_secondary_press_starts_no_drag() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let event = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: Point::new(150.0, 30.0),
button: PointerButton::Secondary,
});
let sa: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT));
w.event_at(&mut ctx, &event, 0.0);
assert_eq!(w.gesture, Gesture::None);
}
#[test]
fn a_cancel_mid_drag_springs_back_without_firing() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, COMMIT_PX * 2.0);
assert!(
w.offset > COMMIT_PX,
"past the commit line under the finger"
);
at(
&mut w,
&mut state,
PointerPhase::Cancel,
Point::new(150.0 + COMMIT_PX * 2.0, 30.0),
t,
);
assert!(state.is_empty(), "a Cancel never fires the callback");
assert_eq!(w.motion, Motion::SpringBack);
}
#[test]
fn the_exit_slides_fully_out_before_the_height_starts_collapsing() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, COMMIT_PX + 10.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + COMMIT_PX + 10.0, 30.0),
t,
);
assert_eq!(state, vec![1]);
let target = w.leg_to;
assert!(
(target - (WIDTH + DISMISS_FLY_OVERSHOOT)).abs() < 1e-9,
"the slide targets the row's width plus the overshoot"
);
let mut t_ms = 0u64;
let mut collapse_started_at: Option<f64> = None;
let mut frames = 0u32;
loop {
let before = w.collapse;
let (_, needs_frame, needs_layout) = paint_at(&mut w, t_ms, None);
if w.collapse > 0.0 && before == 0.0 {
collapse_started_at = Some((w.offset - 0.0) / target);
assert!(needs_layout, "a collapsing row asks for a relayout");
}
if !needs_frame && !needs_layout {
break;
}
frames += 1;
t_ms += 16;
assert!(frames < 1000, "the exit should settle inside 1000 frames");
}
let handoff = collapse_started_at.expect("the height collapsed at some point");
assert!(
handoff >= DISMISS_COLLAPSE_HANDOFF - 0.05,
"the collapse waits for the slide to be ~90% done, started at {handoff}"
);
assert_eq!(w.collapse, 1.0, "the row ends fully collapsed");
assert_eq!(w.motion, Motion::Rest);
assert_eq!(layout(&mut w).height, 0.0);
}
#[test]
fn a_row_mid_exit_ignores_a_fresh_press() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let t = slow_drag(&mut w, &mut state, COMMIT_PX + 10.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + COMMIT_PX + 10.0, 30.0),
t,
);
assert_eq!(w.motion, Motion::Exit);
at(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(150.0, 30.0),
t + 10.0,
);
assert_eq!(w.gesture, Gesture::None, "the exit owns the row");
assert_eq!(w.motion, Motion::Exit);
}
#[test]
fn without_on_dismissed_a_committed_release_still_only_springs_back() {
let mut w = build(&view());
layout(&mut w);
let mut state: u32 = 0;
let t = slow_drag(&mut w, &mut state, COMMIT_PX * 2.0);
at(
&mut w,
&mut state,
PointerPhase::Up,
Point::new(150.0 + COMMIT_PX * 2.0, 30.0),
t,
);
assert_eq!(w.motion, Motion::SpringBack);
}
#[test]
fn no_band_is_painted_at_rest() {
let mut w =
build(&recording().background(dismiss_background().color(Color::from_rgb8(1, 2, 3))));
layout(&mut w);
let (rec, _, _) = paint_at(&mut w, 0, None);
assert!(rec.rrects.is_empty());
assert_eq!(rec.transforms, vec![Affine::translate(Vec2::new(0.0, 0.0))]);
}
#[test]
fn the_band_reveals_from_the_leading_edge_on_a_rightward_drag() {
let mut w =
build(&recording().background(dismiss_background().color(Color::from_rgb8(1, 2, 3))));
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
slow_drag(&mut w, &mut state, COMMIT_PX);
let (rec, _, _) = paint_at(&mut w, 0, None);
let (band_origin, band_size, radius, _) = rec.rrects[0];
assert_eq!(band_origin.x, 0.0, "anchored to the leading edge");
assert!((band_size.width - (COMMIT_PX - DISMISS_ACTION_GAP)).abs() < 1e-9);
assert_eq!(band_size.height, ROW_HEIGHT);
assert_eq!(radius, DISMISS_BACKGROUND_RADIUS);
assert_eq!(
rec.transforms,
vec![Affine::translate(Vec2::new(COMMIT_PX, 0.0))]
);
}
#[test]
fn the_secondary_band_reveals_from_the_trailing_edge_on_a_leftward_drag() {
let mut w = build(
&recording()
.background(dismiss_background().color(Color::from_rgb8(1, 2, 3)))
.secondary_background(dismiss_background().color(Color::from_rgb8(9, 9, 9))),
);
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
slow_drag(&mut w, &mut state, -COMMIT_PX);
let (rec, _, _) = paint_at(&mut w, 0, None);
let (band_origin, band_size, _, color) = rec.rrects[0];
let expected_width = COMMIT_PX - DISMISS_ACTION_GAP;
assert!((band_origin.x - (WIDTH - expected_width)).abs() < 1e-9);
assert!((band_size.width - expected_width).abs() < 1e-9);
let secondary = Color::from_rgb8(9, 9, 9).components;
assert_eq!(
[
color.components[0],
color.components[1],
color.components[2]
],
[secondary[0], secondary[1], secondary[2]],
"the leftward band uses secondary_background, not background"
);
}
#[test]
fn a_leftward_drag_falls_back_to_the_primary_band_when_no_secondary_is_set() {
let mut w =
build(&recording().background(dismiss_background().color(Color::from_rgb8(1, 2, 3))));
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
slow_drag(&mut w, &mut state, -COMMIT_PX);
let (rec, _, _) = paint_at(&mut w, 0, None);
assert_eq!(rec.rrects.len(), 1, "the primary band stands in");
}
#[test]
fn the_band_icon_only_fades_in_past_the_ramp_start() {
let wide_commit = WIDTH * 0.5;
let with_icon = || {
recording().threshold(0.5).background(
dismiss_background()
.color(Color::from_rgb8(1, 2, 3))
.icon(crate::icons::DELETE),
)
};
let mut w = build(&with_icon());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
let short = wide_commit * (ICON_FADE_START / 2.0);
slow_drag(&mut w, &mut state, short);
assert!(w.progress_at(w.offset) < ICON_FADE_START);
assert!(short - DISMISS_ACTION_GAP > 0.0, "the band has real width");
let (rec, _, _) = paint_at(&mut w, 0, None);
assert_eq!(rec.rrects.len(), 1);
assert!(rec.paths.is_empty(), "the icon is still fully transparent");
let mut w = build(&with_icon());
layout(&mut w);
slow_drag(&mut w, &mut state, wide_commit);
assert_eq!(w.progress_at(w.offset), 1.0);
let (rec, _, _) = paint_at(&mut w, 0, None);
assert_eq!(rec.paths.len(), 1, "the icon is painted at full progress");
}
#[test]
fn a_row_without_a_background_paints_no_band() {
let mut w = build(&recording());
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
slow_drag(&mut w, &mut state, COMMIT_PX);
let (rec, _, _) = paint_at(&mut w, 0, None);
assert!(rec.rrects.is_empty());
}
#[test]
fn the_band_resolves_the_error_container_roles_from_a_theme() {
let theme = crate::baseline();
let mut w = build(&recording().background(dismiss_background()));
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
slow_drag(&mut w, &mut state, COMMIT_PX);
let (rec, _, _) = paint_at(&mut w, 0, Some(&theme));
let expected = theme.scheme().error_container.components;
let painted = rec.rrects[0].3.components;
assert_eq!(
[painted[0], painted[1], painted[2]],
[expected[0], expected[1], expected[2]]
);
}
#[test]
fn an_untouched_row_reports_its_childs_height() {
let mut w = build(&view());
let size = layout(&mut w);
assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT));
}
#[test]
fn a_half_collapsed_row_reports_half_its_height() {
let mut w = build(&view());
layout(&mut w);
w.collapse = 0.5;
assert_eq!(layout(&mut w).height, ROW_HEIGHT / 2.0);
assert_eq!(
w.child_height, ROW_HEIGHT,
"the child stays laid out at its own height"
);
}
#[test]
fn the_child_is_clipped_to_the_reported_height() {
let mut w = build(&view());
layout(&mut w);
w.collapse = 0.5;
let (rec, _, _) = paint_at(&mut w, 0, None);
assert_eq!(
rec.clips,
vec![(Point::ZERO, Size::new(WIDTH, ROW_HEIGHT / 2.0))]
);
}
#[test]
fn semantics_forwards_the_child_without_a_node_of_its_own() {
fn logic(_s: &mut ()) -> DismissibleView<()> {
dismissible(frust::text("row")).on_dismissed(|_: &mut (), _| {})
}
let mut root: frust_core::RenderRoot<(), DismissibleView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(WIDTH, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
assert!(
!update.nodes.is_empty(),
"the wrapped row's own nodes still reach the tree"
);
}
#[test]
fn leaving_the_row_disabled_mid_drag_clears_the_gesture() {
let enabled = recording();
let mut w = build(&enabled);
layout(&mut w);
let mut state: Vec<u32> = Vec::new();
slow_drag(&mut w, &mut state, COMMIT_PX);
assert!(matches!(w.gesture, Gesture::Dragging { .. }));
let disabled = recording().enabled(false);
let mut counter = 0u64;
<DismissibleView<Vec<u32>> as View<Vec<u32>>>::rebuild(
&disabled,
&enabled,
&mut w,
&mut BuildCtx::new(&mut counter),
);
assert_eq!(w.gesture, Gesture::None);
assert_eq!(w.offset, 0.0);
assert_eq!(w.motion, Motion::Rest);
}
}