use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::Role;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust::input::VelocityTracker;
use frust::{Curve, FrameTime};
use kurbo::{Affine, Point, Rect, RoundedRect, RoundedRectRadii, Shape, Size};
use peniko::{Brush, Color};
use frust::Timing;
use frust::{BackPolicy, NavigatorController, PopResult, PushOptions};
use frust::{TransitionDriver, TransitionSpec, make_driver};
use crate::press::presses;
const SCRIM_ALPHA: f32 = 0.6;
const HANDLE_TOUCH_TARGET: f64 = 44.0;
const HANDLE_WIDTH: f64 = 32.0;
const HANDLE_HEIGHT: f64 = 3.0;
const DRAG_DISMISS_FRACTION: f64 = 0.5;
const FLICK_DISMISS_VELOCITY: f64 = 700.0;
const RADIUS: f64 = 14.0;
const CONTAINER: Color = Color::from_rgb8(0x1e, 0x24, 0x30);
const BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44);
const BORDER_WIDTH: f64 = 1.0;
const PATH_TOLERANCE: f64 = 0.1;
const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const HANDLE_COLOR: Color = Color::from_rgb8(0x8a, 0x8f, 0x98);
const SHADOW_Y: f64 = 12.0;
const SHADOW_BLUR: f64 = 32.0;
const SHADOW_ALPHA: f32 = 0.45;
const ENTER_DURATION: Duration = Duration::from_millis(220);
const ENTER_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const EXIT_DURATION: Duration = Duration::from_millis(150);
const EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const SETTLE_CURVE: Curve = Curve::Cubic(0.16, 1.0, 0.3, 1.0);
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_timings(theme: Option<&Theme>) -> (Timing, Timing) {
if theme.map(|t| t.motion.reduce_motion).unwrap_or(false) {
let t = Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
return (t, t);
}
match theme {
Some(t) => (
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.base / 1000.0),
t.motion.easing.spatial,
),
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.fast / 1000.0),
t.motion.easing.exit,
),
),
None => (
Timing::Duration(ENTER_DURATION, ENTER_CURVE),
Timing::Duration(EXIT_DURATION, EXIT_CURVE),
),
}
}
fn resolve_settle(theme: Option<&Theme>) -> Timing {
if theme.map(|t| t.motion.reduce_motion).unwrap_or(false) {
return Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
}
match theme {
Some(t) => Timing::Duration(
Duration::from_secs_f64(t.motion.durations.fast / 1000.0),
t.motion.easing.effects,
),
None => Timing::Duration(EXIT_DURATION, SETTLE_CURVE),
}
}
fn resolve_container(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_high,
None => CONTAINER,
}
}
fn resolve_border(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().outline,
None => BORDER,
}
}
fn resolve_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(t) => t.shape.large,
None => RADIUS,
}
}
fn resolve_scrim(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().scrim,
None => SCRIM,
}
}
fn resolve_handle(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().on_surface_variant,
None => HANDLE_COLOR,
}
}
fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
match theme {
Some(t) => {
let s = t.elevation.level5.shadow(t.brightness);
(
s.y_offset,
s.blur_std_dev,
with_alpha(t.scheme().shadow, s.color_alpha),
)
}
None => (SHADOW_Y, SHADOW_BLUR, with_alpha(SCRIM, SHADOW_ALPHA)),
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
type OnClose = Rc<dyn Fn()>;
pub struct GlyphSheetView<State: 'static> {
content: AnyView<State>,
on_close: Option<OnClose>,
dismissable: bool,
dismiss_signal: Option<Rc<Cell<u64>>>,
}
pub fn glyph_sheet<State: 'static, V: View<State>>(content: V) -> GlyphSheetView<State> {
GlyphSheetView {
content: any(content),
on_close: None,
dismissable: true,
dismiss_signal: None,
}
}
#[allow(non_snake_case)]
pub fn GlyphSheet<State: 'static, V: View<State>>(content: V) -> GlyphSheetView<State> {
glyph_sheet(content)
}
impl<State: 'static> GlyphSheetView<State> {
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn on_close<F: Fn() + 'static>(mut self, on_close: F) -> Self {
self.on_close = Some(Rc::new(on_close));
self
}
}
#[derive(Clone, Debug)]
pub struct GlyphSheetHandle {
signal: Rc<Cell<u64>>,
}
impl GlyphSheetHandle {
pub fn close(&self) {
self.signal.set(self.signal.get().wrapping_add(1));
}
}
pub fn show_glyph_sheet<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) -> GlyphSheetHandle
where
State: 'static,
B: Fn() -> GlyphSheetView<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let close_ctrl = controller.clone();
let dismissable = build().dismissable;
let signal = 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 dismissable {
options = options.dismiss_signal(signal.clone());
}
controller.push_with_options(
move || {
let ctrl = close_ctrl.clone();
let mut view = build();
let caller_close = view.on_close.take();
let mut view = view.on_close(move || {
ctrl.pop();
if let Some(on_close) = &caller_close {
on_close();
}
});
view.dismiss_signal = Some(widget_signal.clone());
any::<State, _>(view)
},
options,
);
GlyphSheetHandle { signal }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Enter,
Shown,
Exit,
Dismissed,
}
pub struct GlyphSheetWidget {
content: ChildPod,
on_close: Option<OnClose>,
dismissable: bool,
dismiss_signal: Option<Rc<Cell<u64>>>,
last_seen_dismiss: u64,
panel: Rect,
handle_target: Rect,
phase: Phase,
driver: Option<TransitionDriver>,
drag_active: bool,
drag_start_y: f64,
drag_y: f64,
drag_tracks: bool,
tracker: VelocityTracker,
last_frame_time: FrameTime,
exit_from: f64,
settle_from: Option<f64>,
settle_timing: Timing,
offset_frac: f64,
scrim_captured: bool,
scrim_down_outside: bool,
}
impl<State: 'static> View<State> for GlyphSheetView<State> {
type Element = GlyphSheetWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> GlyphSheetWidget {
GlyphSheetWidget {
content: frust::authoring::build_child(&self.content, ctx),
on_close: self.on_close.clone(),
dismissable: self.dismissable,
dismiss_signal: self.dismiss_signal.clone(),
last_seen_dismiss: 0,
panel: Rect::ZERO,
handle_target: Rect::ZERO,
phase: Phase::Enter,
driver: None,
drag_active: false,
drag_start_y: 0.0,
drag_y: 0.0,
drag_tracks: false,
tracker: VelocityTracker::new(),
last_frame_time: FrameTime::ZERO,
exit_from: 0.0,
settle_from: None,
settle_timing: Timing::Duration(EXIT_DURATION, SETTLE_CURVE),
offset_frac: 1.0,
scrim_captured: false,
scrim_down_outside: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut GlyphSheetWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let flags = frust::authoring::rebuild_child(
&prev.content,
&self.content,
&mut element.content,
ctx,
);
element.on_close = self.on_close.clone();
element.dismissable = self.dismissable;
element.dismiss_signal = self.dismiss_signal.clone();
flags
}
fn teardown(&self, element: &mut GlyphSheetWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.content, &mut element.content, ctx);
}
}
impl GlyphSheetWidget {
fn begin_exit(&mut self) {
self.begin_exit_from(self.current_offset_frac());
}
fn begin_exit_from(&mut self, from: f64) {
if matches!(self.phase, Phase::Enter | Phase::Shown) {
self.phase = Phase::Exit;
self.exit_from = from.clamp(0.0, 1.0);
self.offset_frac = self.exit_from;
self.driver = None;
self.settle_from = None;
}
}
fn start_settle(&mut self, from: f64) {
if self.phase != Phase::Shown || from <= 0.0 {
return;
}
self.settle_from = Some(from.clamp(0.0, 1.0));
self.offset_frac = from;
self.driver = None;
}
fn drag_offset_frac(&self) -> f64 {
let panel_h = self.panel.height();
if !self.drag_tracks || panel_h <= 0.0 {
return 0.0;
}
((self.drag_y - self.drag_start_y) / panel_h).clamp(0.0, 1.0)
}
fn current_offset_frac(&self) -> f64 {
if self.drag_active && self.phase == Phase::Shown {
self.drag_offset_frac()
} else {
self.offset_frac
}
}
fn event_time_ms(&self) -> f64 {
self.last_frame_time.as_secs_f64() * 1000.0
}
fn observe_dismiss_signal(&mut self) {
if let Some(signal) = &self.dismiss_signal {
let current = signal.get();
if current != self.last_seen_dismiss {
self.last_seen_dismiss = current;
self.begin_exit();
}
}
}
fn advance(&mut self, now: FrameTime, enter: Timing, exit: Timing) -> (f64, f32, bool) {
let (offset, animating) = match self.phase {
Phase::Enter => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(enter).0)
.advance(now);
if adv.done {
self.phase = Phase::Shown;
self.driver = None;
}
(1.0 - adv.value.clamp(0.0, 1.0), !adv.done)
}
Phase::Shown => self.advance_shown(now),
Phase::Exit => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(exit).0)
.advance(now);
let q = adv.value.clamp(0.0, 1.0);
if adv.done {
self.phase = Phase::Dismissed;
self.driver = None;
if let Some(on_close) = &self.on_close {
on_close();
}
}
(self.exit_from + (1.0 - self.exit_from) * q, !adv.done)
}
Phase::Dismissed => (1.0, false),
};
self.offset_frac = offset;
(offset, (1.0 - offset) as f32, animating)
}
fn advance_shown(&mut self, now: FrameTime) -> (f64, bool) {
if self.drag_active {
return (self.drag_offset_frac(), self.drag_tracks);
}
let Some(from) = self.settle_from else {
return (0.0, false);
};
let timing = self.settle_timing;
let adv = self
.driver
.get_or_insert_with(|| make_driver(timing).0)
.advance(now);
if adv.done {
self.settle_from = None;
self.driver = None;
return (0.0, false);
}
(from * (1.0 - adv.value.clamp(0.0, 1.0)), true)
}
}
impl Widget for GlyphSheetWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area_w = finite_or_zero(bc.max().width);
let area_h = finite_or_zero(bc.max().height);
let content_max_h = (area_h - HANDLE_TOUCH_TARGET).max(0.0);
let content_bc = BoxConstraints::loose(Size::new(area_w, content_max_h));
let content_size = self.content.layout_child(ctx, &content_bc);
let panel_h = (HANDLE_TOUCH_TARGET + content_size.height).min(area_h);
let panel_y = area_h - panel_h;
self.panel = Rect::new(0.0, panel_y, area_w, area_h);
self.handle_target = Rect::new(0.0, panel_y, area_w, panel_y + HANDLE_TOUCH_TARGET);
let content_x = ((area_w - content_size.width) / 2.0).max(0.0);
self.content
.set_origin(Point::new(content_x, panel_y + HANDLE_TOUCH_TARGET));
bc.constrain(Size::new(area_w, area_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.last_frame_time = ctx.frame_time();
self.observe_dismiss_signal();
let theme = Theme::from_paint_ctx(ctx);
let (enter, exit) = resolve_timings(theme);
self.settle_timing = resolve_settle(theme);
let (offset_frac, scrim_frac, animating) = self.advance(ctx.frame_time(), enter, exit);
let scrim = with_alpha(resolve_scrim(theme), SCRIM_ALPHA * scrim_frac);
scene.fill_rect(ctx.origin(), ctx.size(), scrim);
let panel_h = self.panel.height();
scene.push_transform(Affine::translate((0.0, offset_frac * panel_h)));
let panel_origin = Point::new(
ctx.origin().x + self.panel.x0,
ctx.origin().y + self.panel.y0,
);
let panel_size = Size::new(self.panel.width(), panel_h);
let radius = resolve_radius(theme);
let (shadow_y, shadow_blur, shadow_color) = resolve_shadow(theme);
scene.draw_shadow(
Point::new(panel_origin.x, panel_origin.y + shadow_y),
panel_size,
radius,
shadow_blur,
shadow_color,
);
let radii = RoundedRectRadii::new(radius, radius, 0.0, 0.0);
let path = RoundedRect::from_rect(Rect::from_origin_size(Point::ORIGIN, panel_size), radii)
.to_path(PATH_TOLERANCE);
scene.fill_path(panel_origin, &path, &Brush::Solid(resolve_container(theme)));
scene.stroke_path(
panel_origin,
&path,
BORDER_WIDTH,
&Brush::Solid(resolve_border(theme)),
);
let handle_x = self.panel.x0 + (self.panel.width() - HANDLE_WIDTH) / 2.0;
let handle_y = self.panel.y0 + (HANDLE_TOUCH_TARGET - HANDLE_HEIGHT) / 2.0;
scene.fill_rounded_rect(
Point::new(ctx.origin().x + handle_x, ctx.origin().y + handle_y),
Size::new(HANDLE_WIDTH, HANDLE_HEIGHT),
HANDLE_HEIGHT / 2.0,
resolve_handle(theme),
);
self.content.paint_child(ctx, scene);
scene.pop_transform();
if animating || self.phase == Phase::Dismissed {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let t = self.event_time_ms();
self.event_at(ctx, event, t)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::Dialog,
|node| node.set_modal(),
|ctx| {
self.content.semantics_child(ctx);
},
);
}
frust::authoring::visit_children!(content);
}
impl GlyphSheetWidget {
fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
if event.is_broadcast() {
frust::authoring::route_event_single(&mut self.content, ctx, event);
return EventResult::Ignored;
}
if self.drag_active {
let InputEvent::Pointer(p) = event else {
return EventResult::Handled;
};
return match p.phase {
PointerPhase::Move => {
self.drag_y = p.position.y;
self.tracker.record(t_ms, p.position.y);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
self.drag_y = p.position.y;
self.tracker.record(t_ms, p.position.y);
let offset = self.drag_offset_frac();
let flicked_down = self.tracker.velocity() >= FLICK_DISMISS_VELOCITY;
self.drag_active = false;
if self.dismissable && self.drag_tracks {
if offset >= DRAG_DISMISS_FRACTION || flicked_down {
self.begin_exit_from(offset);
} else {
self.start_settle(offset);
}
}
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
let offset = self.drag_offset_frac();
self.drag_active = false;
self.start_settle(offset);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Down => EventResult::Handled,
};
}
if self.scrim_captured {
let InputEvent::Pointer(p) = event else {
return EventResult::Handled;
};
return match p.phase {
PointerPhase::Up => {
let released_outside = !self.panel.contains(p.position);
if self.dismissable && self.scrim_down_outside && released_outside {
self.begin_exit();
}
self.scrim_captured = false;
EventResult::Handled
}
PointerPhase::Cancel => {
self.scrim_captured = false;
EventResult::Handled
}
_ => EventResult::Handled,
};
}
let InputEvent::Pointer(p) = event else {
if let InputEvent::Key(key_event) = event
&& self.dismissable
&& key_event.key == Key::Named(NamedKey::Escape)
&& !self.content.is_focused()
{
self.begin_exit();
return EventResult::Handled;
}
return frust::authoring::route_event_single(&mut self.content, ctx, event);
};
match p.phase {
PointerPhase::Down => {
ctx.request_focus();
if presses(p)
&& self.phase == Phase::Shown
&& self.handle_target.contains(p.position)
{
let live = self.current_offset_frac() * self.panel.height();
self.drag_active = true;
self.drag_tracks = self.dismissable;
self.drag_start_y = p.position.y - live;
self.drag_y = p.position.y;
self.settle_from = None;
self.driver = None;
self.tracker.clear();
self.tracker.record(t_ms, p.position.y);
ctx.capture_pointer();
return EventResult::Handled;
}
if frust::authoring::route_event_single(&mut self.content, ctx, event)
== EventResult::Handled
{
return EventResult::Handled;
}
if !presses(p) {
return EventResult::Handled;
}
self.scrim_captured = true;
self.scrim_down_outside = !self.panel.contains(p.position);
ctx.capture_pointer();
EventResult::Handled
}
_ => frust::authoring::route_event_single(&mut self.content, ctx, event),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::NavigatorView;
use frust::authoring::text::TextContext;
use frust::authoring::{
BuildCtx, KeyEvent, Modifiers, PointerButton, PointerEvent, any as core_any,
};
use frust::text;
use frust_core::RenderRoot;
use frust_widgets::navigator;
use frust_widgets::test_support::leaf_any;
use std::any::Any;
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn escape_event() -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
}
fn build(view: &GlyphSheetView<()>) -> GlyphSheetWidget {
let mut counter = 0u64;
View::<()>::build(view, &mut BuildCtx::new(&mut counter))
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
fill_paths: Vec<Color>,
stroke_paths: Vec<Color>,
shadows: usize,
transforms: Vec<Affine>,
transform_pops: u32,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn fill_path(&mut self, _origin: Point, _path: &kurbo::BezPath, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.fill_paths.push(*c);
}
}
fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.stroke_paths.push(*c);
}
}
fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _b: f64, _c: Color) {
self.shadows += 1;
}
fn push_transform(&mut self, t: Affine) {
self.transforms.push(t);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
fn laid_out(view: &GlyphSheetView<()>, area: Size) -> GlyphSheetWidget {
let mut w = build(view);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(area));
w
}
#[test]
fn enter_staging_slides_panel_up_and_fades_scrim_in_independently() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
let (offset0, scrim0, animating0) = w.advance(ft_ms(0.0), enter, exit);
assert!(
(offset0 - 1.0).abs() < 1e-6,
"panel starts fully off-screen"
);
assert!(scrim0.abs() < 1e-6, "scrim starts transparent");
assert!(animating0);
assert_eq!(w.phase, Phase::Enter);
let (offset_mid, scrim_mid, _) = w.advance(ft_ms(60.0), enter, exit);
assert!(
offset_mid > 0.0 && offset_mid < 1.0,
"panel is partway through its slide"
);
assert!(
scrim_mid > 0.0 && scrim_mid < 1.0,
"scrim is partway through its fade"
);
let (offset1, scrim1, animating1) = w.advance(ft_ms(400.0), enter, exit);
assert!((offset1 - 0.0).abs() < 1e-6, "panel is at rest");
assert!((scrim1 - 1.0).abs() < 1e-6, "scrim is fully opaque");
assert!(!animating1);
assert_eq!(w.phase, Phase::Shown);
}
#[test]
fn exit_is_faster_than_enter_and_reverses_the_staging() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(400.0), enter, exit); w.begin_exit();
assert_eq!(w.phase, Phase::Exit);
let (offset_seed, scrim_seed, _) = w.advance(ft_ms(400.0), enter, exit);
assert!((offset_seed - 0.0).abs() < 1e-6);
assert!((scrim_seed - 1.0).abs() < 1e-6);
let (_, _, animating_mid) = w.advance(ft_ms(400.0 + 149.0), enter, exit);
assert!(animating_mid);
let (offset_end, scrim_end, animating_end) = w.advance(ft_ms(400.0 + 200.0), enter, exit);
assert!((offset_end - 1.0).abs() < 1e-6);
assert!(scrim_end.abs() < 1e-6);
assert!(!animating_end);
assert_eq!(w.phase, Phase::Dismissed);
}
#[test]
fn on_close_fires_once_when_the_exit_completes() {
let count = Rc::new(Cell::new(0u32));
let c = count.clone();
let view: GlyphSheetView<()> =
glyph_sheet(leaf_any(300.0, 200.0)).on_close(move || c.set(c.get() + 1));
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(400.0), enter, exit);
w.begin_exit();
w.advance(ft_ms(400.0), enter, exit);
assert_eq!(count.get(), 0, "on_close waits for the exit to finish");
w.advance(ft_ms(700.0), enter, exit);
assert_eq!(count.get(), 1);
w.advance(ft_ms(900.0), enter, exit);
assert_eq!(count.get(), 1, "idempotent past Dismissed");
}
#[test]
fn paint_emits_scrim_before_the_panel_transform_and_the_panel_after() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let area = Size::new(400.0, 600.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rects[0].1, area);
assert!(
rec.rects[0].2.components[3] < 1e-3,
"scrim starts transparent"
);
assert_eq!(rec.transforms.len(), 1);
assert_eq!(rec.transform_pops, 1);
let t = rec.transforms[0];
let coeffs = t.as_coeffs();
assert_eq!((coeffs[0], coeffs[3]), (1.0, 1.0), "no scale component");
assert!(
coeffs[5] > 0.0,
"panel is translated downward at enter start"
);
assert_eq!(rec.fill_paths.len(), 1, "the panel surface fill");
assert_eq!(rec.stroke_paths.len(), 1, "the panel border stroke");
assert_eq!(rec.shadows, 1, "the chrome-level shadow");
assert!(
pctx.needs_frame(),
"an in-flight enter requests another frame"
);
}
#[test]
fn scrim_tap_outside_the_panel_begins_exit() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = laid_out(&view, Size::new(400.0, 600.0));
w.advance(ft_ms(0.0), resolve_timings(None).0, resolve_timings(None).1);
w.advance(
ft_ms(400.0),
resolve_timings(None).0,
resolve_timings(None).1,
);
let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.scrim_captured);
assert!(w.scrim_down_outside);
w.event(&mut ctx, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(
w.phase,
Phase::Exit,
"an outside tap begins the staged exit"
);
}
#[test]
fn handle_drag_past_threshold_begins_exit_not_an_immediate_pop() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = laid_out(&view, Size::new(400.0, 600.0));
w.advance(ft_ms(0.0), resolve_timings(None).0, resolve_timings(None).1);
w.advance(
ft_ms(400.0),
resolve_timings(None).0,
resolve_timings(None).1,
);
let handle_y = w.panel.y0 + 10.0; let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, &ev(PointerPhase::Down, 200.0, handle_y));
assert!(w.drag_active);
w.event(&mut ctx, &ev(PointerPhase::Up, 200.0, handle_y + 150.0));
assert!(!w.drag_active);
assert_eq!(
w.phase,
Phase::Exit,
"a past-threshold drag begins the staged exit, not an immediate pop"
);
}
#[test]
fn handle_drag_under_threshold_does_not_dismiss() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = laid_out(&view, Size::new(400.0, 600.0));
w.advance(ft_ms(0.0), resolve_timings(None).0, resolve_timings(None).1);
w.advance(
ft_ms(400.0),
resolve_timings(None).0,
resolve_timings(None).1,
);
let handle_y = w.panel.y0 + 10.0;
let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, &ev(PointerPhase::Down, 200.0, handle_y));
w.event(&mut ctx, &ev(PointerPhase::Up, 200.0, handle_y + 10.0));
assert_eq!(
w.phase,
Phase::Shown,
"a short drag settles back, no dismiss"
);
}
#[test]
fn dismissable_false_ignores_scrim_and_drag() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0)).dismissable(false);
let mut w = laid_out(&view, Size::new(400.0, 600.0));
w.advance(ft_ms(0.0), resolve_timings(None).0, resolve_timings(None).1);
w.advance(
ft_ms(400.0),
resolve_timings(None).0,
resolve_timings(None).1,
);
let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
w.event(&mut ctx, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(
w.phase,
Phase::Shown,
"scrim tap ignored when non-dismissable"
);
let handle_y = w.panel.y0 + 10.0;
w.event(&mut ctx, &ev(PointerPhase::Down, 200.0, handle_y));
w.event(&mut ctx, &ev(PointerPhase::Up, 200.0, handle_y + 150.0));
assert_eq!(
w.phase,
Phase::Shown,
"handle drag ignored when non-dismissable"
);
}
#[test]
fn escape_begins_exit_when_focused_and_content_is_not() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = laid_out(&view, Size::new(400.0, 600.0));
w.advance(ft_ms(0.0), resolve_timings(None).0, resolve_timings(None).1);
w.advance(
ft_ms(400.0),
resolve_timings(None).0,
resolve_timings(None).1,
);
let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, &ev(PointerPhase::Down, 200.0, w.panel.y0 + 60.0));
w.event(&mut ctx, &ev(PointerPhase::Up, 200.0, w.panel.y0 + 60.0));
w.event(&mut ctx, &escape_event());
assert_eq!(w.phase, Phase::Exit);
}
#[test]
fn a_down_reclaims_focus_after_an_external_blur() {
use frust::authoring::ChildPod;
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let area = Size::new(400.0, 600.0);
let mut counter = 0u64;
let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let mut pod = ChildPod::new(Box::new(w));
let mut lctx = LayoutCtx::new();
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut dummy = ();
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
}
assert!(pod.is_focused(), "the first Down claims focus");
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Up, 10.0, 10.0));
}
pod.set_focused(false);
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
}
assert!(
pod.is_focused(),
"a second Down must re-claim focus after an external blur — this is what \
keeps the root's focus/IME session alive while the sheet is up"
);
}
#[test]
fn dismiss_signal_bump_begins_exit() {
let signal = Rc::new(Cell::new(0u64));
let mut view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
view.dismiss_signal = Some(signal.clone());
let mut w = laid_out(&view, Size::new(400.0, 600.0));
w.advance(ft_ms(0.0), resolve_timings(None).0, resolve_timings(None).1);
w.advance(
ft_ms(400.0),
resolve_timings(None).0,
resolve_timings(None).1,
);
assert_eq!(w.phase, Phase::Shown);
signal.set(1);
w.observe_dismiss_signal();
assert_eq!(w.phase, Phase::Exit);
}
#[test]
fn layout_anchors_panel_to_bottom_with_handle_strip() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let area = Size::new(400.0, 600.0);
let size = w.layout(&mut lctx, &BoxConstraints::tight(area));
assert_eq!(size, area, "the sheet fills the whole area (scrim)");
assert_eq!(w.panel.x0, 0.0);
assert_eq!(w.panel.x1, area.width);
assert_eq!(w.panel.y1, area.height, "panel bottom is the screen edge");
assert_eq!(w.panel.height(), HANDLE_TOUCH_TARGET + 200.0);
assert_eq!(w.handle_target.y0, w.panel.y0);
assert_eq!(w.handle_target.height(), HANDLE_TOUCH_TARGET);
assert_eq!(w.content.origin().y, w.panel.y0 + HANDLE_TOUCH_TARGET);
}
#[test]
fn semantics_is_a_modal_dialog_container() {
fn logic(_s: &mut ()) -> GlyphSheetView<()> {
glyph_sheet(leaf_any(300.0, 200.0))
}
let mut root: RenderRoot<(), GlyphSheetView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(400.0, 600.0));
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(node.is_modal(), "the sheet node sets the modal flag");
}
#[test]
fn semantics_forwards_the_content_subtree() {
fn logic(_s: &mut ()) -> GlyphSheetView<()> {
glyph_sheet(text("pane picker".to_string()))
}
let mut root: RenderRoot<(), GlyphSheetView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, dialog_node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(
!dialog_node.children().is_empty(),
"the content subtree's own semantics reached the tree — it was forwarded"
);
}
#[test]
fn show_glyph_sheet_pushes_transparent_with_no_page_transition() {
let controller = frust::NavigatorController::<()>::new();
let nav: NavigatorView<()> = navigator(&controller, || {
core_any::<(), _>(leaf_any_widget(400.0, 600.0))
});
let mut w = build_nav(&nav);
super::show_glyph_sheet(
&controller,
|| glyph_sheet(leaf_any(300.0, 200.0)),
|_: &mut (), _result: PopResult| {},
);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
}
fn build_nav(view: &NavigatorView<()>) -> <NavigatorView<()> as View<()>>::Element {
let mut counter = 0u64;
View::<()>::build(view, &mut BuildCtx::new(&mut counter))
}
fn leaf_any_widget(
w: f64,
h: f64,
) -> impl View<(), Element = frust_widgets::test_support::LeafWidget> {
frust_widgets::test_support::leaf(w, h)
}
#[derive(Default)]
struct NavState {
results: usize,
}
struct Fixed {
size: Size,
}
struct FixedWidget {
size: Size,
}
fn fixed(width: f64, height: f64) -> Fixed {
Fixed {
size: Size::new(width, height),
}
}
impl View<NavState> for Fixed {
type Element = FixedWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> FixedWidget {
FixedWidget { size: self.size }
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut FixedWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for FixedWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
fn area() -> Size {
Size::new(400.0, 600.0)
}
const ENTER_SETTLED_MS: f64 = 400.0;
type NavApp = Box<dyn FnMut(&mut NavState) -> NavigatorView<NavState>>;
struct Harness {
root: RenderRoot<NavState, NavigatorView<NavState>>,
app: NavApp,
state: NavState,
tcx: TextContext,
}
impl Harness {
fn new(controller: &NavigatorController<NavState>) -> Self {
let ctrl = controller.clone();
let mut harness = Harness {
root: RenderRoot::new(),
app: Box::new(move |_: &mut NavState| {
navigator(&ctrl, || core_any::<NavState, _>(fixed(400.0, 600.0)))
}),
state: NavState::default(),
tcx: TextContext::new(),
};
harness.rebuild();
harness
}
fn rebuild(&mut self) {
self.root.rebuild(&mut self.app, &mut self.state);
self.root
.layout_with_text(area(), &mut self.tcx as &mut dyn Any);
}
fn paint(&mut self, ms: f64) {
self.paint_recorded(ms);
}
fn paint_recorded(&mut self, ms: f64) -> Recorder {
let mut rec = Recorder::default();
self.root.paint(&mut rec, ft_ms(ms));
rec
}
fn event(&mut self, event: &InputEvent) {
self.root.event(&mut self.state, event);
}
fn open(&mut self) {
self.rebuild();
self.paint(0.0);
self.paint(ENTER_SETTLED_MS);
}
fn flush(&mut self) {
self.rebuild();
self.event(&ev(PointerPhase::Move, 5.0, 5.0));
}
}
fn scrim_is_mid_fade(rec: &Recorder) -> bool {
rec.rects.iter().any(|(_, size, color)| {
*size == area() && color.components[3] > 0.0 && color.components[3] < SCRIM_ALPHA
})
}
fn open_sheet(controller: &NavigatorController<NavState>) -> (Harness, GlyphSheetHandle) {
let mut harness = Harness::new(controller);
let handle = show_glyph_sheet(
controller,
|| glyph_sheet(fixed(300.0, 200.0)),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
harness.open();
(harness, handle)
}
#[test]
fn handle_close_plays_the_staged_exit_and_pops_on_completion() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let (mut h, sheet) = open_sheet(&controller);
assert_eq!(controller.depth(), 2, "the sheet page is up");
sheet.close();
h.paint(ENTER_SETTLED_MS);
let rec = h.paint_recorded(ENTER_SETTLED_MS + 75.0);
h.rebuild();
assert_eq!(controller.depth(), 2, "the sheet is exiting, not unmounted");
assert_eq!(h.state.results, 0, "the pop waits for the exit to finish");
assert!(scrim_is_mid_fade(&rec), "the scrim is mid-fade");
assert!(
rec.transforms.iter().any(|t| {
let c = t.as_coeffs();
c[5] > 0.0 && (c[0], c[3]) == (1.0, 1.0)
}),
"the panel is mid-slide under a pure downward translate"
);
h.paint(ENTER_SETTLED_MS + 200.0);
h.flush();
assert_eq!(controller.depth(), 1, "the completed exit popped the page");
assert_eq!(h.state.results, 1, "exactly one pop");
}
#[test]
fn repeat_closes_and_a_close_mid_exit_still_pop_exactly_once() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let closes = Rc::new(Cell::new(0u32));
let counter = closes.clone();
let sheet = show_glyph_sheet(
&controller,
move || {
let counter = counter.clone();
glyph_sheet(fixed(300.0, 200.0)).on_close(move || counter.set(counter.get() + 1))
},
|state: &mut NavState, _result: PopResult| state.results += 1,
);
h.open();
sheet.close();
sheet.close(); h.paint(ENTER_SETTLED_MS);
sheet.close(); h.paint(ENTER_SETTLED_MS + 100.0);
h.paint(ENTER_SETTLED_MS + 200.0); sheet.close(); h.paint(ENTER_SETTLED_MS + 300.0);
h.flush();
assert_eq!(closes.get(), 1, "one close callback — so one pop");
assert_eq!(h.state.results, 1);
assert_eq!(controller.depth(), 1);
}
#[test]
fn a_close_during_a_drag_exit_is_absorbed_into_the_one_pop() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let (mut h, sheet) = open_sheet(&controller);
let handle_y = area().height - (HANDLE_TOUCH_TARGET + 200.0) + 10.0;
h.event(&ev(PointerPhase::Down, 200.0, handle_y));
h.event(&ev(PointerPhase::Up, 200.0, handle_y + 150.0));
h.paint(ENTER_SETTLED_MS); let rec = h.paint_recorded(ENTER_SETTLED_MS + 75.0);
assert!(
scrim_is_mid_fade(&rec),
"the drag's staged exit is genuinely in flight"
);
sheet.close(); h.paint(ENTER_SETTLED_MS + 200.0);
h.flush();
assert_eq!(controller.depth(), 1);
assert_eq!(h.state.results, 1, "one exit, one pop");
}
#[test]
fn the_callers_on_close_composes_with_the_pop_and_runs_after_it() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let calls = Rc::new(Cell::new(0u32));
let counter = calls.clone();
let push_ctrl = controller.clone();
let sheet = show_glyph_sheet(
&controller,
move || {
let counter = counter.clone();
let ctrl = push_ctrl.clone();
glyph_sheet(fixed(300.0, 200.0)).on_close(move || {
counter.set(counter.get() + 1);
ctrl.push(|| core_any::<NavState, _>(fixed(400.0, 600.0)));
})
},
|state: &mut NavState, _result: PopResult| state.results += 1,
);
h.open();
sheet.close();
h.paint(ENTER_SETTLED_MS);
h.paint(ENTER_SETTLED_MS + 200.0);
h.flush();
assert_eq!(calls.get(), 1, "the caller's `on_close` ran exactly once");
assert_eq!(
h.state.results, 1,
"…and the wired pop ran too, once — the sheet's own page is gone \
(caller-first would have queued the push ahead of the pop, so the \
pop would have taken the pushed page and left the sheet up)"
);
assert_eq!(
controller.depth(),
2,
"pop first, callback second: the page the callback pushed survives"
);
}
#[test]
fn a_non_dismissable_sheet_vetoes_back_but_still_closes_programmatically() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let sheet = show_glyph_sheet(
&controller,
|| glyph_sheet(fixed(300.0, 200.0)).dismissable(false),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
h.open();
controller.request_back();
h.rebuild();
h.paint(500.0);
h.paint(900.0);
h.flush();
assert_eq!(
controller.depth(),
2,
"a back press leaves a non-dismissable sheet up"
);
assert_eq!(h.state.results, 0);
sheet.close();
h.paint(1000.0);
h.rebuild();
assert_eq!(controller.depth(), 2, "the exit plays before the pop");
h.paint(1200.0);
h.flush();
assert_eq!(controller.depth(), 1);
assert_eq!(h.state.results, 1);
}
const PANEL_H: f64 = HANDLE_TOUCH_TARGET + 200.0;
fn shown(view: &GlyphSheetView<()>) -> GlyphSheetWidget {
let (enter, exit) = resolve_timings(None);
let mut w = laid_out(view, area());
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(ENTER_SETTLED_MS), enter, exit);
assert_eq!(w.phase, Phase::Shown, "the sheet settled shown");
w
}
fn painted(w: &mut GlyphSheetWidget, ms: f64) -> (f64, f32, bool) {
let mut rec = Recorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, area(), ft_ms(ms));
w.paint(&mut pctx, &mut rec);
(
rec.transforms[0].as_coeffs()[5],
rec.rects[0].2.components[3],
pctx.needs_frame(),
)
}
fn drag_event(w: &mut GlyphSheetWidget, phase: PointerPhase, y: f64, t_ms: f64) -> EventResult {
let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, area());
w.event_at(&mut ctx, &ev(phase, 200.0, y), t_ms)
}
fn panel_translate(rec: &Recorder) -> f64 {
let mut pure: Vec<f64> = rec
.transforms
.iter()
.map(|t| t.as_coeffs())
.filter(|c| (c[0], c[3]) == (1.0, 1.0))
.map(|c| c[5])
.collect();
assert_eq!(pure.len(), 1, "one pure translate — the panel's own slide");
pure.pop().unwrap()
}
#[test]
fn a_handle_drag_moves_the_panel_under_the_finger_and_dims_the_scrim_with_it() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = shown(&view);
let handle_y = w.panel.y0 + 10.0;
let (ty, alpha, frame) = painted(&mut w, ENTER_SETTLED_MS);
assert_eq!(ty, 0.0);
assert!((alpha - SCRIM_ALPHA).abs() < 1e-6);
assert!(!frame, "a resting sheet requests no frames");
drag_event(&mut w, PointerPhase::Down, handle_y, ENTER_SETTLED_MS);
drag_event(
&mut w,
PointerPhase::Move,
handle_y + 60.0,
ENTER_SETTLED_MS + 16.0,
);
let (ty, alpha, frame) = painted(&mut w, ENTER_SETTLED_MS + 16.0);
assert!(
(ty - 60.0).abs() < 1e-6,
"the panel tracks the finger 1:1, got {ty}"
);
let expected = SCRIM_ALPHA * (1.0 - 60.0 / PANEL_H) as f32;
assert!(
(alpha - expected).abs() < 1e-6,
"the scrim dims on the drag's own progress: {alpha} vs {expected}"
);
assert!(frame, "a live drag keeps the frame pump open");
assert_eq!(w.phase, Phase::Shown, "dragging is not a phase");
drag_event(
&mut w,
PointerPhase::Move,
handle_y - 200.0,
ENTER_SETTLED_MS + 32.0,
);
let (ty, alpha, _) = painted(&mut w, ENTER_SETTLED_MS + 32.0);
assert_eq!(ty, 0.0, "clamped at rest");
assert!((alpha - SCRIM_ALPHA).abs() < 1e-6);
}
#[test]
fn a_release_past_the_threshold_exits_from_the_dragged_offset_and_pops_once() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let (mut h, _sheet) = open_sheet(&controller);
let handle_y = area().height - PANEL_H + 10.0;
h.event(&ev(PointerPhase::Down, 200.0, handle_y));
h.paint(ENTER_SETTLED_MS + 16.0);
h.event(&ev(PointerPhase::Move, 200.0, handle_y + 150.0));
let mid = h.paint_recorded(ENTER_SETTLED_MS + 200.0);
assert!(
(panel_translate(&mid) - 150.0).abs() < 1e-6,
"the panel followed the finger before the release"
);
h.event(&ev(PointerPhase::Up, 200.0, handle_y + 150.0));
let seed = h.paint_recorded(ENTER_SETTLED_MS + 216.0);
let ty = panel_translate(&seed);
assert!(
(ty - 150.0).abs() < 1e-6,
"the exit starts from the dragged offset, got {ty}"
);
let onward = h.paint_recorded(ENTER_SETTLED_MS + 291.0);
assert!(
panel_translate(&onward) > 150.0,
"…and only ever continues downward from it"
);
assert_eq!(controller.depth(), 2, "still mounted, still exiting");
h.paint(ENTER_SETTLED_MS + 600.0);
h.flush();
assert_eq!(controller.depth(), 1, "the completed exit popped the page");
assert_eq!(h.state.results, 1, "exactly one pop");
}
#[test]
fn a_downward_flick_dismisses_a_drag_that_never_reached_the_threshold() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let (mut h, _sheet) = open_sheet(&controller);
let handle_y = area().height - PANEL_H + 10.0;
h.event(&ev(PointerPhase::Down, 200.0, handle_y));
h.paint(ENTER_SETTLED_MS + 16.0);
h.event(&ev(PointerPhase::Move, 200.0, handle_y + 30.0));
h.paint(ENTER_SETTLED_MS + 32.0);
h.event(&ev(PointerPhase::Move, 200.0, handle_y + 60.0));
h.event(&ev(PointerPhase::Up, 200.0, handle_y + 60.0));
let seed = h.paint_recorded(ENTER_SETTLED_MS + 48.0);
assert!(
(panel_translate(&seed) - 60.0).abs() < 1e-6,
"a flicked dismissal also resumes from the finger's offset"
);
h.paint(ENTER_SETTLED_MS + 400.0);
h.flush();
assert_eq!(
controller.depth(),
1,
"the flick dismissed a quarter-dragged sheet"
);
assert_eq!(h.state.results, 1);
}
#[test]
fn a_slow_release_below_the_threshold_springs_back_and_leaves_the_sheet_usable() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = shown(&view);
let handle_y = w.panel.y0 + 10.0;
drag_event(&mut w, PointerPhase::Down, handle_y, 400.0);
drag_event(&mut w, PointerPhase::Move, handle_y + 60.0, 600.0);
let (ty, _, _) = painted(&mut w, 600.0);
assert!((ty - 60.0).abs() < 1e-6);
drag_event(&mut w, PointerPhase::Up, handle_y + 60.0, 600.0);
assert_eq!(w.phase, Phase::Shown, "no dismissal");
let from = w.settle_from.expect("a spring-back is in flight instead");
assert!(
(from - 60.0 / PANEL_H).abs() < 1e-9,
"…starting from the released offset"
);
let (seed, _, frame) = painted(&mut w, 600.0);
assert!(
(seed - 60.0).abs() < 1e-6,
"the spring-back starts where the finger let go, got {seed}"
);
assert!(frame);
let (partway, _, frame) = painted(&mut w, 675.0);
assert!(
partway > 0.0 && partway < 60.0,
"partway home, got {partway}"
);
assert!(frame);
let (home, alpha, frame) = painted(&mut w, 800.0);
assert_eq!(home, 0.0, "back at rest");
assert!(
(alpha - SCRIM_ALPHA).abs() < 1e-6,
"the scrim came back with it"
);
assert!(!frame, "a settled sheet stops requesting frames");
assert_eq!(w.settle_from, None);
drag_event(&mut w, PointerPhase::Down, handle_y, 800.0);
drag_event(&mut w, PointerPhase::Move, handle_y + 150.0, 1000.0);
drag_event(&mut w, PointerPhase::Up, handle_y + 150.0, 1000.0);
assert_eq!(w.phase, Phase::Exit, "a later drag still dismisses");
}
#[test]
fn a_cancelled_drag_springs_back_and_never_dismisses() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let mut w = shown(&view);
let handle_y = w.panel.y0 + 10.0;
drag_event(&mut w, PointerPhase::Down, handle_y, 400.0);
drag_event(&mut w, PointerPhase::Move, handle_y + 150.0, 416.0);
drag_event(&mut w, PointerPhase::Cancel, handle_y + 150.0, 416.0);
assert_eq!(w.phase, Phase::Shown, "a cancel dismisses nothing");
assert!(w.settle_from.is_some(), "…it springs back instead");
assert!(!w.drag_active);
painted(&mut w, 416.0);
let (home, _, frame) = painted(&mut w, 700.0);
assert_eq!(home, 0.0, "back at rest");
assert!(!frame);
}
#[test]
fn a_non_dismissable_sheets_panel_is_immovable_but_still_swallows_the_press() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0)).dismissable(false);
let mut w = shown(&view);
let handle_y = w.panel.y0 + 10.0;
assert_eq!(
drag_event(&mut w, PointerPhase::Down, handle_y, 400.0),
EventResult::Handled,
"the press is swallowed — it must not reach content or the page below"
);
assert!(w.drag_active, "the gesture is owned…");
assert!(!w.drag_tracks, "…but it moves nothing");
drag_event(&mut w, PointerPhase::Move, handle_y + 150.0, 416.0);
let (ty, alpha, frame) = painted(&mut w, 416.0);
assert_eq!(
ty, 0.0,
"the panel does not budge, and does not rubber-band"
);
assert!(
(alpha - SCRIM_ALPHA).abs() < 1e-6,
"so the scrim does not move either"
);
assert!(!frame, "an immovable panel asks for no frames");
drag_event(&mut w, PointerPhase::Up, handle_y + 150.0, 432.0);
assert_eq!(w.phase, Phase::Shown, "no dismissal, past-threshold or not");
assert_eq!(w.settle_from, None, "and nothing to spring back");
}
#[test]
fn a_close_mid_drag_wins_and_exits_from_where_the_finger_had_the_panel() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let (mut h, sheet) = open_sheet(&controller);
let handle_y = area().height - PANEL_H + 10.0;
h.event(&ev(PointerPhase::Down, 200.0, handle_y));
h.event(&ev(PointerPhase::Move, 200.0, handle_y + 100.0));
let dragging = h.paint_recorded(ENTER_SETTLED_MS + 16.0);
assert!((panel_translate(&dragging) - 100.0).abs() < 1e-6);
sheet.close();
let seed = h.paint_recorded(ENTER_SETTLED_MS + 32.0);
assert!(
(panel_translate(&seed) - 100.0).abs() < 1e-6,
"the exit picks the panel up at the finger's offset"
);
h.event(&ev(PointerPhase::Up, 200.0, handle_y + 100.0));
h.paint(ENTER_SETTLED_MS + 400.0);
h.flush();
assert_eq!(controller.depth(), 1);
assert_eq!(h.state.results, 1, "one exit, one pop");
}
#[test]
fn a_handle_press_during_the_enter_never_becomes_a_drag() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let (enter, exit) = resolve_timings(None);
let mut w = laid_out(&view, area());
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(60.0), enter, exit);
assert_eq!(w.phase, Phase::Enter, "mid-enter");
let handle_y = w.panel.y0 + 10.0;
drag_event(&mut w, PointerPhase::Down, handle_y, 60.0);
assert!(
!w.drag_active,
"the phase machine owns the panel mid-enter — no drag to fight it"
);
drag_event(&mut w, PointerPhase::Up, handle_y + 150.0, 76.0);
assert_eq!(w.phase, Phase::Enter, "no dismissal");
let (_, _, animating) = w.advance(ft_ms(120.0), enter, exit);
assert!(animating, "…and the enter itself is undisturbed");
}
#[test]
fn a_dismissal_mid_enter_exits_from_the_partly_entered_offset() {
let view: GlyphSheetView<()> = glyph_sheet(leaf_any(300.0, 200.0));
let (enter, exit) = resolve_timings(None);
let mut w = laid_out(&view, area());
w.advance(ft_ms(0.0), enter, exit);
let (mid, _, _) = w.advance(ft_ms(60.0), enter, exit);
assert!(mid > 0.0 && mid < 1.0, "partway in, got {mid}");
w.begin_exit();
let (seed, _, _) = w.advance(ft_ms(60.0), enter, exit);
assert!(
(seed - mid).abs() < 1e-9,
"the exit resumes from the entering panel's own offset, not from rest"
);
}
#[test]
fn a_close_before_the_sheet_first_paints_is_still_honoured() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let sheet = show_glyph_sheet(
&controller,
|| glyph_sheet(fixed(300.0, 200.0)),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
sheet.close();
h.rebuild();
h.paint(0.0);
h.paint(100.0);
h.paint(300.0);
h.flush();
assert_eq!(controller.depth(), 1, "the sheet closed itself out");
assert_eq!(h.state.results, 1);
}
}