use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::Role;
use frust::authoring::text::{FontWeight, LineHeight, TextOverflow};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, ThemeTextType,
View, Widget, any,
};
use frust::text;
use frust::{Curve, FrameTime};
use kurbo::{Affine, BezPath, Point, Rect, 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 BARRIER_EPSILON: f32 = 1e-3;
const WIDTH_FRACTION: f64 = 0.83;
pub const GLYPH_SIDE_SHEET_MAX_WIDTH: f64 = 284.0;
const BORDER_WIDTH: f64 = 1.0;
const HAIRLINE: f64 = 1.0;
const HEADER_PAD_LEADING: f64 = 16.0;
const HEADER_PAD_TRAILING: f64 = 14.0;
const HEADER_PAD_TOP: f64 = 16.0;
const HEADER_PAD_BOTTOM: f64 = 13.0;
const TITLE_CLOSE_GAP: f64 = 10.0;
const CLOSE_DIAMETER: f64 = 26.0;
const CLOSE_TOUCH_TARGET: f64 = 44.0;
const CLOSE_ARM: f64 = 5.0;
const CLOSE_STROKE_WIDTH: f64 = 1.5;
const HEADER_HEIGHT: f64 = HEADER_PAD_TOP + CLOSE_DIAMETER + HEADER_PAD_BOTTOM;
const FOOTER_PAD: f64 = HEADER_PAD_LEADING;
const BODY_PAD_X: f64 = 12.0;
const TITLE_SIZE: f32 = 15.0;
const TITLE_WEIGHT: FontWeight = FontWeight::BOLD;
const TITLE_LINE_HEIGHT: f32 = 20.0;
const CONTAINER: Color = Color::from_rgb8(0x1e, 0x24, 0x30);
const BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44);
const DIVIDER: Color = Color::from_rgb8(0x2a, 0x2d, 0x33);
const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const CLOSE_FILL: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
const CLOSE_INK: Color = Color::from_rgb8(0xa3, 0x9c, 0x88);
const CLOSE_INK_PRESSED: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
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 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_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_divider(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().outline_variant,
None => DIVIDER,
}
}
fn resolve_scrim(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().scrim,
None => SCRIM,
}
}
fn resolve_close_fill(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_highest,
None => CLOSE_FILL,
}
}
fn resolve_close_ink(theme: Option<&Theme>, pressed: bool) -> Color {
match (theme, pressed) {
(Some(t), false) => t.scheme().on_surface_variant,
(Some(t), true) => t.scheme().primary,
(None, false) => CLOSE_INK,
(None, true) => CLOSE_INK_PRESSED,
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
fn panel_width(width: f64) -> f64 {
(WIDTH_FRACTION * width).clamp(0.0, GLYPH_SIDE_SHEET_MAX_WIDTH)
}
fn title_view<State: 'static>(s: &str) -> AnyView<State> {
any::<State, _>(
text(s.to_string())
.size(TITLE_SIZE)
.weight(TITLE_WEIGHT)
.line_height(LineHeight::Absolute(TITLE_LINE_HEIGHT))
.max_lines(1)
.overflow(TextOverflow::Ellipsis)
.themed_family(ThemeTextType::HeadlineSmall),
)
}
type OnClose = Rc<dyn Fn()>;
pub struct GlyphSideSheetView<State: 'static> {
title: String,
body: AnyView<State>,
footer: Option<AnyView<State>>,
on_close: Option<OnClose>,
dismissable: bool,
dismiss_signal: Option<Rc<Cell<u64>>>,
}
pub fn glyph_side_sheet<State: 'static, V: View<State>>(
title: impl Into<String>,
body: V,
) -> GlyphSideSheetView<State> {
GlyphSideSheetView {
title: title.into(),
body: any(body),
footer: None,
on_close: None,
dismissable: true,
dismiss_signal: None,
}
}
#[allow(non_snake_case)]
pub fn GlyphSideSheet<State: 'static, V: View<State>>(
title: impl Into<String>,
body: V,
) -> GlyphSideSheetView<State> {
glyph_side_sheet(title, body)
}
impl<State: 'static> GlyphSideSheetView<State> {
pub fn footer(mut self, footer: AnyView<State>) -> Self {
self.footer = Some(footer);
self
}
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 GlyphSideSheetHandle {
signal: Rc<Cell<u64>>,
}
impl GlyphSideSheetHandle {
pub fn close(&self) {
self.signal.set(self.signal.get().wrapping_add(1));
}
}
pub fn show_glyph_side_sheet<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) -> GlyphSideSheetHandle
where
State: 'static,
B: Fn() -> GlyphSideSheetView<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());
view.dismissable = dismissable;
any::<State, _>(view)
},
options,
);
GlyphSideSheetHandle { signal }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Enter,
Shown,
Exit,
Dismissed,
}
fn reconcile_footer<State: 'static>(
prev: Option<&AnyView<State>>,
next: Option<&AnyView<State>>,
pod: &mut Option<ChildPod>,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
match (prev, next) {
(None, Some(v)) => {
*pod = Some(frust::authoring::build_child(v, ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(v), None) => {
if let Some(p) = pod.as_mut() {
frust::authoring::teardown_child(v, p, ctx);
}
*pod = None;
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(a), Some(b)) => match pod.as_mut() {
Some(p) => frust::authoring::rebuild_child(a, b, p, ctx),
None => ChangeFlags::NONE,
},
(None, None) => ChangeFlags::NONE,
}
}
pub struct GlyphSideSheetWidget {
title: ChildPod,
title_text: String,
body: ChildPod,
footer: Option<ChildPod>,
on_close: Option<OnClose>,
dismissable: bool,
dismiss_signal: Option<Rc<Cell<u64>>>,
last_seen_dismiss: u64,
panel: Rect,
body_rect: Rect,
close_rect: Rect,
close_center: Point,
footer_divider_y: f64,
top_inset: f64,
header_height: f64,
phase: Phase,
driver: Option<TransitionDriver>,
exit_from: f64,
offset_frac: f64,
close_pressed: bool,
close_captured: bool,
scrim_captured: bool,
scrim_down_outside: bool,
}
impl<State: 'static> View<State> for GlyphSideSheetView<State> {
type Element = GlyphSideSheetWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> GlyphSideSheetWidget {
GlyphSideSheetWidget {
title: frust::authoring::build_child(&title_view::<State>(&self.title), ctx),
title_text: self.title.clone(),
body: frust::authoring::build_child(&self.body, ctx),
footer: self
.footer
.as_ref()
.map(|v| frust::authoring::build_child(v, ctx)),
on_close: self.on_close.clone(),
dismissable: self.dismissable,
dismiss_signal: self.dismiss_signal.clone(),
last_seen_dismiss: 0,
panel: Rect::ZERO,
body_rect: Rect::ZERO,
close_rect: Rect::ZERO,
close_center: Point::ZERO,
footer_divider_y: 0.0,
top_inset: 0.0,
header_height: HEADER_HEIGHT,
phase: Phase::Enter,
driver: None,
exit_from: 0.0,
offset_frac: 1.0,
close_pressed: false,
close_captured: false,
scrim_captured: false,
scrim_down_outside: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut GlyphSideSheetWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = frust::authoring::rebuild_child(
&title_view::<State>(&prev.title),
&title_view::<State>(&self.title),
&mut element.title,
ctx,
);
element.title_text = self.title.clone();
flags |= frust::authoring::rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
flags |= reconcile_footer(
prev.footer.as_ref(),
self.footer.as_ref(),
&mut element.footer,
ctx,
);
element.on_close = self.on_close.clone();
element.dismiss_signal = self.dismiss_signal.clone();
flags
}
fn teardown(&self, element: &mut GlyphSideSheetWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(
&title_view::<State>(&self.title),
&mut element.title,
ctx,
);
frust::authoring::teardown_child(&self.body, &mut element.body, ctx);
if let (Some(v), Some(p)) = (&self.footer, element.footer.as_mut()) {
frust::authoring::teardown_child(v, p, ctx);
}
}
}
impl GlyphSideSheetWidget {
fn begin_exit(&mut self) {
if matches!(self.phase, Phase::Enter | Phase::Shown) {
self.phase = Phase::Exit;
self.exit_from = self.offset_frac.clamp(0.0, 1.0);
self.driver = None;
}
}
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 => (0.0, false),
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 barrier_is_live(&self) -> bool {
(1.0 - self.offset_frac) as f32 > BARRIER_EPSILON
}
fn content_is_focused(&self) -> bool {
self.body.is_focused() || self.footer.as_ref().is_some_and(|p| p.is_focused())
}
fn route_to_content(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if frust::authoring::route_event_single(&mut self.body, ctx, event) == EventResult::Handled
{
return EventResult::Handled;
}
match self.footer.as_mut() {
Some(pod) => frust::authoring::route_event_single(pod, ctx, event),
None => EventResult::Ignored,
}
}
fn paint_close(&self, origin: Point, fill: Color, ink: Color, scene: &mut dyn PaintScene) {
let cx = origin.x + self.close_center.x;
let cy = origin.y + self.close_center.y;
scene.fill_rounded_rect(
Point::new(cx - CLOSE_DIAMETER / 2.0, cy - CLOSE_DIAMETER / 2.0),
Size::new(CLOSE_DIAMETER, CLOSE_DIAMETER),
CLOSE_DIAMETER / 2.0,
fill,
);
let mut a = BezPath::new();
a.move_to((cx - CLOSE_ARM, cy - CLOSE_ARM));
a.line_to((cx + CLOSE_ARM, cy + CLOSE_ARM));
let mut b = BezPath::new();
b.move_to((cx + CLOSE_ARM, cy - CLOSE_ARM));
b.line_to((cx - CLOSE_ARM, cy + CLOSE_ARM));
scene.stroke_path(Point::ZERO, &a, CLOSE_STROKE_WIDTH, &Brush::Solid(ink));
scene.stroke_path(Point::ZERO, &b, CLOSE_STROKE_WIDTH, &Brush::Solid(ink));
}
}
impl Widget for GlyphSideSheetWidget {
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 insets = ctx.window_insets().padding();
let top_inset = insets.top;
let bottom_inset = insets.bottom;
self.top_inset = top_inset;
let panel_w = panel_width(area_w);
let panel_x = area_w - panel_w;
self.panel = Rect::new(panel_x, 0.0, area_w, area_h);
let header_height = top_inset + HEADER_HEIGHT;
self.header_height = header_height;
let trailing_reserve = if self.dismissable {
HEADER_PAD_TRAILING + CLOSE_DIAMETER + TITLE_CLOSE_GAP
} else {
HEADER_PAD_TRAILING
};
let title_max_w = (panel_w - HEADER_PAD_LEADING - trailing_reserve).max(0.0);
let title_size = self.title.layout_child(
ctx,
&BoxConstraints::loose(Size::new(title_max_w, f64::INFINITY)),
);
self.title.set_origin(Point::new(
panel_x + HEADER_PAD_LEADING,
top_inset + ((HEADER_HEIGHT - title_size.height) / 2.0).max(0.0),
));
if self.dismissable {
self.close_center = Point::new(
area_w - HEADER_PAD_TRAILING - CLOSE_DIAMETER / 2.0,
top_inset + HEADER_HEIGHT / 2.0,
);
self.close_rect = Rect::from_center_size(
self.close_center,
Size::new(CLOSE_TOUCH_TARGET, CLOSE_TOUCH_TARGET),
);
} else {
self.close_center = Point::ZERO;
self.close_rect = Rect::ZERO;
}
let mut footer_h = 0.0;
if let Some(pod) = self.footer.as_mut() {
let footer_bottom_pad = FOOTER_PAD + bottom_inset;
let inner_w = (panel_w - 2.0 * BODY_PAD_X).max(0.0);
let max_h =
(area_h - header_height - HAIRLINE - FOOTER_PAD - footer_bottom_pad).max(0.0);
let size = pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner_w, max_h)));
footer_h = HAIRLINE + FOOTER_PAD + size.height + footer_bottom_pad;
let divider_y = area_h - footer_h;
self.footer_divider_y = divider_y;
pod.set_origin(Point::new(
panel_x + BODY_PAD_X,
divider_y + HAIRLINE + FOOTER_PAD,
));
}
let body_h = (area_h - header_height - footer_h).max(0.0);
let body_bottom_pad = if self.footer.is_some() {
0.0
} else {
bottom_inset
};
let inner_body_w = (panel_w - 2.0 * BODY_PAD_X).max(0.0);
let inner_body_h = (body_h - body_bottom_pad).max(0.0);
self.body.layout_child(
ctx,
&BoxConstraints::tight(Size::new(inner_body_w, inner_body_h)),
);
self.body
.set_origin(Point::new(panel_x + BODY_PAD_X, header_height));
self.body_rect = Rect::new(panel_x, header_height, area_w, header_height + body_h);
bc.constrain(Size::new(area_w, area_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.observe_dismiss_signal();
let theme = Theme::from_paint_ctx(ctx);
let (enter, exit) = resolve_timings(theme);
let was_dismissed = self.phase == Phase::Dismissed;
let (offset_frac, scrim_frac, animating) = self.advance(ctx.frame_time(), enter, exit);
let just_dismissed = !was_dismissed && self.phase == Phase::Dismissed;
let scrim = with_alpha(resolve_scrim(theme), SCRIM_ALPHA * scrim_frac);
scene.fill_rect(ctx.origin(), ctx.size(), scrim);
let panel_w = self.panel.width();
scene.push_transform(Affine::translate((offset_frac * panel_w, 0.0)));
let origin = ctx.origin();
let panel_origin = Point::new(origin.x + self.panel.x0, origin.y + self.panel.y0);
let panel_size = Size::new(panel_w, self.panel.height());
scene.fill_rect(panel_origin, panel_size, resolve_container(theme));
scene.fill_rect(
panel_origin,
Size::new(BORDER_WIDTH, panel_size.height),
resolve_border(theme),
);
let divider = resolve_divider(theme);
let close_fill = resolve_close_fill(theme);
let close_ink = resolve_close_ink(theme, self.close_pressed);
scene.fill_rect(
Point::new(panel_origin.x, origin.y + self.header_height - HAIRLINE),
Size::new(panel_w, HAIRLINE),
divider,
);
self.title.paint_child(ctx, scene);
if self.dismissable {
self.paint_close(origin, close_fill, close_ink, scene);
}
scene.push_clip(
Point::new(origin.x + self.body_rect.x0, origin.y + self.body_rect.y0),
self.body_rect.size(),
);
self.body.paint_child(ctx, scene);
scene.pop_clip();
if let Some(pod) = self.footer.as_mut() {
scene.fill_rect(
Point::new(panel_origin.x, origin.y + self.footer_divider_y),
Size::new(panel_w, HAIRLINE),
divider,
);
pod.paint_child(ctx, scene);
}
scene.pop_transform();
if animating || just_dismissed {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
frust::authoring::route_event_single(&mut self.body, ctx, event);
if let Some(pod) = self.footer.as_mut() {
frust::authoring::route_event_single(pod, ctx, event);
}
return EventResult::Ignored;
}
if !self.barrier_is_live() {
self.close_pressed = false;
self.close_captured = false;
self.scrim_captured = false;
return EventResult::Ignored;
}
if self.close_captured {
let InputEvent::Pointer(p) = event else {
return EventResult::Handled;
};
return match p.phase {
PointerPhase::Move => {
self.close_pressed = self.close_rect.contains(p.position);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
if self.dismissable && self.close_rect.contains(p.position) {
self.begin_exit();
}
self.close_pressed = false;
self.close_captured = false;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
self.close_pressed = false;
self.close_captured = false;
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 self.route_to_content(ctx, event);
};
match p.phase {
PointerPhase::Down => {
ctx.request_focus();
if presses(p) && self.phase == Phase::Shown && self.close_rect.contains(p.position)
{
self.close_pressed = true;
self.close_captured = true;
ctx.capture_pointer();
ctx.request_redraw();
return EventResult::Handled;
}
if self.route_to_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
}
_ => self.route_to_content(ctx, event),
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let label = self.title_text.clone();
let dismissable = self.dismissable;
ctx.push_container(
Role::Dialog,
|node| {
node.set_modal();
node.set_label(label.as_str());
},
|ctx| {
self.title.semantics_child(ctx);
if dismissable {
ctx.push_node(Role::Button, |node| node.set_label("Close"));
}
self.body.semantics_child(ctx);
if let Some(pod) = &self.footer {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(title, body, footer);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::NavigatorView;
use frust::authoring::text::TextContext;
use frust::authoring::{
BuildCtx, KeyEvent, Modifiers, PointerButton, PointerEvent, ScrollDelta, WindowEdgeInsets,
WindowInsets, any as core_any,
};
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: &GlyphSideSheetView<()>) -> GlyphSideSheetWidget {
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)>,
stroke_paths: Vec<Color>,
clips: Vec<(Point, Size)>,
clip_pops: u32,
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 stroke_path(&mut self, _o: Point, _p: &BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.stroke_paths.push(*c);
}
}
fn push_clip(&mut self, origin: Point, size: Size) {
self.clips.push((origin, size));
}
fn pop_clip(&mut self) {
self.clip_pops += 1;
}
fn push_transform(&mut self, t: Affine) {
self.transforms.push(t);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
fn area() -> Size {
Size::new(400.0, 600.0)
}
const ENTER_SETTLED_MS: f64 = 400.0;
fn laid_out(view: &GlyphSideSheetView<()>, size: Size) -> GlyphSideSheetWidget {
let mut w = build(view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(size));
w
}
fn shown(view: &GlyphSideSheetView<()>) -> GlyphSideSheetWidget {
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 rebuild_into(
w: &mut GlyphSideSheetWidget,
prev: &GlyphSideSheetView<()>,
next: &GlyphSideSheetView<()>,
) {
let mut counter = 0u64;
View::<()>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(area()));
}
fn paint_needs_frame(w: &mut GlyphSideSheetWidget, ms: f64) -> bool {
let mut rec = Recorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, area(), ft_ms(ms));
w.paint(&mut pctx, &mut rec);
pctx.needs_frame()
}
fn dismiss_and_settle(w: &mut GlyphSideSheetWidget) {
w.begin_exit();
paint_needs_frame(w, ENTER_SETTLED_MS);
paint_needs_frame(w, ENTER_SETTLED_MS + 200.0);
assert_eq!(w.phase, Phase::Dismissed, "the exit ran to completion");
}
fn paint_at(w: &mut GlyphSideSheetWidget, ms: f64, theme: Option<&Theme>) -> Recorder {
let mut rec = Recorder::default();
let mut pctx = match theme {
Some(t) => PaintCtx::for_test(Point::ZERO, area(), ft_ms(ms)).with_theme(t),
None => PaintCtx::for_test(Point::ZERO, area(), ft_ms(ms)),
};
w.paint(&mut pctx, &mut rec);
rec
}
fn dispatch(w: &mut GlyphSideSheetWidget, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = &mut ();
let mut ctx = EventCtx::new(state_any, Point::ZERO, area());
w.event(&mut ctx, event)
}
fn sheet() -> GlyphSideSheetView<()> {
glyph_side_sheet("Filters", leaf_any(300.0, 200.0))
}
struct Greedy;
struct GreedyWidget;
impl View<()> for Greedy {
type Element = GreedyWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> GreedyWidget {
GreedyWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut GreedyWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for GreedyWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, _event: &InputEvent) -> EventResult {
EventResult::Handled
}
}
fn greedy_sheet() -> GlyphSideSheetView<()> {
glyph_side_sheet("Filters", Greedy)
}
fn scroll_at(x: f64, y: f64) -> InputEvent {
InputEvent::Scroll {
position: Point::new(x, y),
delta: ScrollDelta::Lines(0.0, -1.0),
}
}
fn panel_translate(rec: &Recorder) -> f64 {
let mut pure: Vec<[f64; 6]> = rec
.transforms
.iter()
.map(|t| t.as_coeffs())
.filter(|c| (c[0], c[3]) == (1.0, 1.0))
.collect();
assert_eq!(pure.len(), 1, "one pure translate — the panel's own slide");
let c = pure.pop().unwrap();
assert_eq!(c[5], 0.0, "the side sheet slides on x only");
c[4]
}
#[test]
fn layout_pins_a_capped_panel_to_the_trailing_edge_at_full_height() {
let mut w = laid_out(&sheet(), area());
assert_eq!(w.panel.width(), GLYPH_SIDE_SHEET_MAX_WIDTH);
assert_eq!(w.panel.x1, area().width, "flush with the trailing edge");
assert_eq!(w.panel.y0, 0.0);
assert_eq!(w.panel.y1, area().height, "full height");
let narrow = Size::new(300.0, 600.0);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let size = w.layout(&mut lctx, &BoxConstraints::tight(narrow));
assert_eq!(size, narrow, "the sheet fills the whole area (scrim)");
assert!((w.panel.width() - 0.83 * 300.0).abs() < 1e-9);
assert_eq!(w.panel.x1, narrow.width);
}
#[test]
fn a_footerless_sheet_gives_the_body_everything_below_the_fixed_header() {
let w = laid_out(&sheet(), area());
assert_eq!(
w.body.origin(),
Point::new(w.panel.x0 + BODY_PAD_X, HEADER_HEIGHT)
);
assert_eq!(
w.body.size(),
Size::new(w.panel.width() - 2.0 * BODY_PAD_X, 600.0 - HEADER_HEIGHT)
);
assert_eq!(w.body_rect.y1, area().height, "…all the way to the bottom");
assert_eq!(w.close_rect.height(), CLOSE_TOUCH_TARGET);
assert!(
w.close_rect.y0 >= 0.0 && w.close_rect.y1 <= HEADER_HEIGHT,
"the inflated close target stays inside the header row"
);
}
#[test]
fn a_footer_pins_to_the_bottom_and_takes_its_height_off_the_body() {
let view = sheet().footer(leaf_any(120.0, 40.0));
let w = laid_out(&view, area());
let footer = w.footer.as_ref().expect("the footer pod was built");
let footer_h = HAIRLINE + 2.0 * FOOTER_PAD + 40.0;
assert_eq!(w.footer_divider_y, area().height - footer_h);
assert_eq!(
footer.origin(),
Point::new(
w.panel.x0 + BODY_PAD_X,
w.footer_divider_y + HAIRLINE + FOOTER_PAD
),
"the footer sits inside its own insets, below its hairline"
);
assert_eq!(
w.body.size(),
Size::new(
w.panel.width() - 2.0 * BODY_PAD_X,
600.0 - HEADER_HEIGHT - footer_h
),
"the body is tight-filled between the header and the footer"
);
assert_eq!(w.body_rect.y1, w.footer_divider_y);
}
#[test]
fn a_non_dismissable_sheet_reclaims_the_close_reserve_for_its_title() {
let dismissable = laid_out(&sheet(), area());
let barrier = laid_out(&sheet().dismissable(false), area());
assert_eq!(barrier.close_rect, Rect::ZERO, "no affordance to hit");
assert!(
barrier.title.size().width >= dismissable.title.size().width,
"the title is given at least the space the chip used to reserve"
);
}
#[test]
fn layout_consumes_the_top_and_bottom_insets_and_keeps_the_horizontal_gutter_footerless() {
fn logic(_s: &mut ()) -> GlyphSideSheetView<()> {
sheet()
}
let mut root: RenderRoot<(), GlyphSideSheetView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.set_insets(WindowInsets::new(
WindowEdgeInsets::new(0.0, 24.0, 0.0, 34.0),
WindowEdgeInsets::ZERO,
));
let mut tcx = TextContext::new();
root.layout_with_text(area(), &mut tcx as &mut dyn Any);
let id = root.root_id().expect("root built");
let w = (root.tree().pod(id).expect("pod").widget() as &dyn Any)
.downcast_ref::<GlyphSideSheetWidget>()
.expect("root is a GlyphSideSheetWidget");
assert_eq!(w.panel.y0, 0.0, "the panel paints under the status bar");
assert_eq!(w.panel.y1, area().height);
assert_eq!(w.top_inset, 24.0);
assert_eq!(w.header_height, HEADER_HEIGHT + 24.0);
assert!(w.title.origin().y >= 24.0, "title below the inset");
assert!(w.close_center.y >= 24.0, "close chip below the inset");
assert_eq!(w.body.origin().y, HEADER_HEIGHT + 24.0);
assert_eq!(
w.body.origin().x,
w.panel.x0 + BODY_PAD_X,
"body children inset by the shared horizontal gutter"
);
assert_eq!(w.body.size().width, w.panel.width() - 2.0 * BODY_PAD_X);
assert_eq!(
w.body.size().height,
area().height - (HEADER_HEIGHT + 24.0) - 34.0,
"the footerless body's own bottom edge gained the bottom inset"
);
}
#[test]
fn a_footers_bottom_padding_grows_by_the_bottom_inset_and_keeps_the_horizontal_gutter() {
fn logic(_s: &mut ()) -> GlyphSideSheetView<()> {
sheet().footer(leaf_any(120.0, 40.0))
}
let mut root: RenderRoot<(), GlyphSideSheetView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.set_insets(WindowInsets::new(
WindowEdgeInsets::new(0.0, 0.0, 0.0, 34.0),
WindowEdgeInsets::ZERO,
));
let mut tcx = TextContext::new();
root.layout_with_text(area(), &mut tcx as &mut dyn Any);
let id = root.root_id().expect("root built");
let w = (root.tree().pod(id).expect("pod").widget() as &dyn Any)
.downcast_ref::<GlyphSideSheetWidget>()
.expect("root is a GlyphSideSheetWidget");
let footer_h = HAIRLINE + 2.0 * FOOTER_PAD + 40.0 + 34.0;
assert_eq!(w.footer_divider_y, area().height - footer_h);
let footer = w.footer.as_ref().expect("the footer pod was built");
assert_eq!(
footer.origin().x,
w.panel.x0 + BODY_PAD_X,
"footer children inset by the shared horizontal gutter"
);
assert_eq!(
w.body.size().height,
area().height - HEADER_HEIGHT - footer_h
);
assert_eq!(w.body.origin().x, w.panel.x0 + BODY_PAD_X);
}
#[test]
fn enter_staging_slides_the_panel_in_and_fades_the_scrim_independently() {
let mut w = build(&sheet());
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);
assert!(scrim_mid > 0.0 && scrim_mid < 1.0);
let (offset1, scrim1, animating1) = w.advance(ft_ms(400.0), enter, exit);
assert!(offset1.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 mut w = build(&sheet());
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.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 a_dismissal_mid_enter_exits_from_the_partly_entered_offset() {
let (enter, exit) = resolve_timings(None);
let mut w = laid_out(&sheet(), 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 reduce_motion_collapses_both_ramps_to_one_linear_crossfade() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let (enter, exit) = resolve_timings(Some(&theme));
let collapsed = Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
assert_eq!(enter, collapsed);
assert_eq!(exit, collapsed);
let mut w = laid_out(&sheet(), area());
w.advance(ft_ms(0.0), enter, exit);
let (offset, _, animating) = w.advance(ft_ms(200.0), enter, exit);
assert!(offset.abs() < 1e-6);
assert!(!animating);
}
#[test]
fn on_close_fires_once_when_the_exit_completes() {
let count = Rc::new(Cell::new(0u32));
let c = count.clone();
let view = sheet().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_the_scrim_before_the_panel_transform_and_slides_on_x_only() {
let mut w = laid_out(&sheet(), area());
let rec = paint_at(&mut w, 0.0, None);
assert_eq!(rec.rects[0].1, area(), "a full-area scrim fill");
assert!(
rec.rects[0].2.components[3] < 1e-3,
"scrim starts transparent"
);
assert_eq!(rec.transforms.len(), 1);
assert_eq!(rec.transform_pops, 1);
assert!(
panel_translate(&rec) > 0.0,
"the panel starts translated out past the trailing edge"
);
assert_eq!(rec.clips.len(), 1, "the component clips its body");
assert_eq!(rec.clip_pops, 1);
}
#[test]
fn unthemed_chrome_paints_the_documented_fallbacks() {
let view = sheet().footer(leaf_any(120.0, 40.0));
let mut w = shown(&view);
let rec = paint_at(&mut w, ENTER_SETTLED_MS, None);
assert_eq!(rec.rects[1].2, CONTAINER, "the panel surface");
assert_eq!(rec.rects[2].2, BORDER, "the leading hairline");
assert_eq!(rec.rects[2].1.width, BORDER_WIDTH);
assert_eq!(rec.rects[3].2, DIVIDER, "the header's bottom hairline");
assert_eq!(rec.rects[3].1.height, HAIRLINE);
assert!(
rec.rects.iter().any(|(o, s, c)| {
o.y == w.footer_divider_y && s.height == HAIRLINE && *c == DIVIDER
}),
"the footer's top hairline"
);
let (_, size, radius, fill) = rec.rrects[0];
assert_eq!(size, Size::new(CLOSE_DIAMETER, CLOSE_DIAMETER));
assert_eq!(radius, CLOSE_DIAMETER / 2.0);
assert_eq!(fill, CLOSE_FILL);
assert_eq!(rec.stroke_paths, vec![CLOSE_INK, CLOSE_INK]);
assert_eq!(rec.clips[0].1, w.body_rect.size());
}
#[test]
fn themed_chrome_resolves_every_value_from_the_theme() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut w = shown(&sheet());
let rec = paint_at(&mut w, ENTER_SETTLED_MS, Some(&theme));
assert_eq!(rec.rects[1].2, scheme.surface_container_high);
assert_eq!(rec.rects[2].2, scheme.outline);
assert_eq!(rec.rects[3].2, scheme.outline_variant);
assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
assert_eq!(rec.stroke_paths[0], scheme.on_surface_variant);
}
#[test]
fn a_settled_sheet_requests_no_frames() {
let mut w = shown(&sheet());
let mut rec = Recorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, area(), ft_ms(ENTER_SETTLED_MS));
w.paint(&mut pctx, &mut rec);
assert!(!pctx.needs_frame());
assert_eq!(panel_translate(&rec), 0.0, "…at rest");
}
#[test]
fn a_scrim_tap_outside_the_panel_begins_the_staged_exit() {
let mut w = shown(&sheet());
dispatch(&mut w, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.scrim_captured);
assert!(w.scrim_down_outside);
dispatch(&mut w, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(w.phase, Phase::Exit);
}
#[test]
fn a_press_inside_the_panel_is_swallowed_and_dismisses_nothing() {
let mut w = shown(&sheet());
let inside = Point::new(w.panel.x0 + 20.0, 300.0);
assert_eq!(
dispatch(&mut w, &ev(PointerPhase::Down, inside.x, inside.y)),
EventResult::Handled,
"the barrier swallows it — it must not reach the page below"
);
dispatch(&mut w, &ev(PointerPhase::Up, inside.x, inside.y));
assert_eq!(w.phase, Phase::Shown);
}
#[test]
fn the_close_affordance_presses_on_down_and_exits_on_up_inside() {
let mut w = shown(&sheet());
let c = w.close_center;
dispatch(&mut w, &ev(PointerPhase::Down, c.x, c.y));
assert!(w.close_captured && w.close_pressed);
let rec = paint_at(&mut w, ENTER_SETTLED_MS, None);
assert_eq!(rec.stroke_paths, vec![CLOSE_INK_PRESSED, CLOSE_INK_PRESSED]);
dispatch(&mut w, &ev(PointerPhase::Up, c.x, c.y));
assert!(!w.close_captured && !w.close_pressed);
assert_eq!(
w.phase,
Phase::Exit,
"the close affordance takes the same staged exit"
);
}
#[test]
fn a_close_press_released_outside_the_affordance_dismisses_nothing() {
let mut w = shown(&sheet());
let c = w.close_center;
dispatch(&mut w, &ev(PointerPhase::Down, c.x, c.y));
dispatch(&mut w, &ev(PointerPhase::Move, c.x, c.y + 200.0));
assert!(!w.close_pressed, "the pressed ink drops with the finger");
dispatch(&mut w, &ev(PointerPhase::Up, c.x, c.y + 200.0));
assert_eq!(w.phase, Phase::Shown);
dispatch(&mut w, &ev(PointerPhase::Down, c.x, c.y));
dispatch(&mut w, &ev(PointerPhase::Cancel, c.x, c.y));
assert_eq!(w.phase, Phase::Shown);
assert!(!w.close_captured);
}
#[test]
fn escape_begins_the_staged_exit() {
let mut w = shown(&sheet());
dispatch(&mut w, &escape_event());
assert_eq!(w.phase, Phase::Exit);
}
#[test]
fn a_dismiss_signal_bump_begins_the_staged_exit() {
let signal = Rc::new(Cell::new(0u64));
let mut view = sheet();
view.dismiss_signal = Some(signal.clone());
let mut w = shown(&view);
signal.set(1);
w.observe_dismiss_signal();
assert_eq!(w.phase, Phase::Exit);
}
#[test]
fn dismissable_false_ignores_the_scrim_and_escape_and_paints_no_affordance() {
let mut w = shown(&sheet().dismissable(false));
dispatch(&mut w, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(w.phase, Phase::Shown, "scrim tap ignored");
dispatch(&mut w, &escape_event());
assert_eq!(w.phase, Phase::Shown, "Escape ignored");
let rec = paint_at(&mut w, ENTER_SETTLED_MS, None);
assert!(rec.rrects.is_empty(), "no close chip is painted");
assert!(rec.stroke_paths.is_empty(), "…and no close glyph either");
}
#[test]
fn flipping_dismissable_off_after_the_push_changes_nothing() {
let pushed = sheet();
let mut w = shown(&pushed);
let close_rect = w.close_rect;
let title_w = w.title.size().width;
rebuild_into(&mut w, &pushed, &sheet().dismissable(false));
assert_eq!(
w.close_rect, close_rect,
"the affordance still has a target"
);
assert_eq!(
w.title.size().width,
title_w,
"…and the title its own width"
);
let rec = paint_at(&mut w, ENTER_SETTLED_MS, None);
assert_eq!(rec.rrects.len(), 1, "the close chip is still painted");
assert_eq!(rec.stroke_paths, vec![CLOSE_INK, CLOSE_INK]);
dispatch(&mut w, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(w.phase, Phase::Exit, "the scrim tap still dismisses");
}
#[test]
fn flipping_dismissable_on_after_a_non_dismissable_push_changes_nothing() {
let pushed = sheet().dismissable(false);
let mut w = shown(&pushed);
rebuild_into(&mut w, &pushed, &sheet());
assert_eq!(w.close_rect, Rect::ZERO, "still no affordance to hit");
let rec = paint_at(&mut w, ENTER_SETTLED_MS, None);
assert!(rec.rrects.is_empty(), "still no close chip");
dispatch(&mut w, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(w.phase, Phase::Shown, "the scrim tap is still ignored");
dispatch(&mut w, &escape_event());
assert_eq!(w.phase, Phase::Shown, "…and so is Escape");
}
#[test]
fn a_dismissed_sheet_stops_requesting_frames_once_the_barrier_is_gone() {
let mut w = shown(&sheet());
w.begin_exit();
assert!(
paint_needs_frame(&mut w, ENTER_SETTLED_MS),
"the exit is running"
);
assert!(
paint_needs_frame(&mut w, ENTER_SETTLED_MS + 200.0),
"the frame the exit lands on still asks for one more — the rebuild \
that applies the pop runs there"
);
assert_eq!(w.phase, Phase::Dismissed);
assert!(
!paint_needs_frame(&mut w, ENTER_SETTLED_MS + 216.0),
"…and then it goes quiet: `Dismissed` is terminal, so a level test \
on the phase would spin frames forever"
);
assert!(!paint_needs_frame(&mut w, ENTER_SETTLED_MS + 5_000.0));
}
#[test]
fn a_dismissed_sheets_barrier_swallows_no_input() {
let mut w = shown(&sheet());
dismiss_and_settle(&mut w);
assert_eq!(
dispatch(&mut w, &ev(PointerPhase::Down, 10.0, 10.0)),
EventResult::Ignored,
"an invisible barrier must let the press reach the page below"
);
assert!(!w.scrim_captured, "…and capture nothing");
let inside = Point::new(w.panel.x0 + 20.0, 300.0);
assert_eq!(
dispatch(&mut w, &ev(PointerPhase::Down, inside.x, inside.y)),
EventResult::Ignored,
"the panel's resting hit rect is off-screen chrome now"
);
assert!(!w.scrim_captured);
assert_eq!(w.phase, Phase::Dismissed, "and nothing re-armed");
}
#[test]
fn a_dismissed_sheet_eats_no_escape() {
let mut w = shown(&sheet());
dismiss_and_settle(&mut w);
assert_eq!(
dispatch(&mut w, &escape_event()),
EventResult::Ignored,
"a gone barrier eats no key: `Escape` belongs to the page below"
);
assert_eq!(w.phase, Phase::Dismissed, "…and nothing re-armed");
}
#[test]
fn a_dismissed_sheet_routes_no_move_scroll_or_up_to_its_off_screen_content() {
let mut w = shown(&greedy_sheet());
let body_origin = w.body.origin();
let inside = Point::new(body_origin.x + 5.0, body_origin.y + 5.0);
assert_eq!(
dispatch(&mut w, &ev(PointerPhase::Move, inside.x, inside.y)),
EventResult::Handled,
"a live sheet routes to its content"
);
dismiss_and_settle(&mut w);
for event in [
ev(PointerPhase::Move, inside.x, inside.y),
ev(PointerPhase::Up, inside.x, inside.y),
scroll_at(inside.x, inside.y),
] {
assert_eq!(
dispatch(&mut w, &event),
EventResult::Ignored,
"an off-screen panel routes nothing to its content"
);
}
assert!(
!w.scrim_captured && !w.close_captured,
"…and captured nothing on the way through"
);
assert_eq!(w.phase, Phase::Dismissed, "…and nothing re-armed");
}
#[test]
fn a_barrier_press_is_released_when_the_barrier_fades_out_under_it() {
let mut w = shown(&sheet());
dispatch(&mut w, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.scrim_captured, "the barrier took the press");
dismiss_and_settle(&mut w);
assert_eq!(
dispatch(&mut w, &ev(PointerPhase::Up, 10.0, 10.0)),
EventResult::Ignored,
"the captured stream falls through once the barrier is gone"
);
assert!(!w.scrim_captured, "…and the capture was dropped");
}
#[test]
fn a_down_reclaims_focus_after_an_external_blur() {
let view = sheet();
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(ENTER_SETTLED_MS), enter, exit);
let mut pod = ChildPod::new(Box::new(w));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
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 semantics_is_a_modal_dialog_labelled_by_the_title() {
fn logic(_s: &mut ()) -> GlyphSideSheetView<()> {
glyph_side_sheet("Filters", leaf_any(300.0, 200.0))
}
let mut root: RenderRoot<(), GlyphSideSheetView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area(), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(node.is_modal(), "the sheet node sets the modal flag");
assert_eq!(node.label(), Some("Filters"));
assert!(
!node.children().is_empty(),
"the header/body subtrees were forwarded"
);
assert!(
update
.nodes
.iter()
.any(|(_, n)| n.role() == Role::Button && n.label() == Some("Close")),
"the close affordance contributes its own button node"
);
}
#[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);
}
}
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, GlyphSideSheetHandle) {
let mut harness = Harness::new(controller);
let handle = show_glyph_side_sheet(
controller,
|| glyph_side_sheet("Filters", fixed(300.0, 200.0)),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
harness.open();
(harness, handle)
}
#[test]
fn show_glyph_side_sheet_pushes_a_transparent_page_with_no_page_transition() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
show_glyph_side_sheet(
&controller,
|| glyph_side_sheet("Filters", fixed(300.0, 200.0)),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
h.rebuild();
let rec = h.paint_recorded(0.0);
assert_eq!(controller.depth(), 2, "the sheet page is up");
assert!(
panel_translate(&rec) > 0.0,
"the panel drives its own slide"
);
assert!(
rec.rects
.iter()
.any(|(_, s, c)| *s == area() && c.components[3] < 1e-3),
"…under its own scrim, which starts transparent"
);
}
#[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);
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!(
panel_translate(&rec) > 0.0,
"the panel is mid-slide toward the trailing edge"
);
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 a_back_press_stages_the_exit_and_leaves_the_depth_alone_until_it_settles() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let (mut h, _sheet) = open_sheet(&controller);
controller.request_back();
h.rebuild();
h.paint(ENTER_SETTLED_MS);
let rec = h.paint_recorded(ENTER_SETTLED_MS + 75.0);
h.rebuild();
assert!(
scrim_is_mid_fade(&rec),
"the back press staged the same reverse ramp"
);
assert_eq!(controller.depth(), 2, "depth unchanged until it settles");
assert_eq!(h.state.results, 0);
h.paint(ENTER_SETTLED_MS + 200.0);
h.flush();
assert_eq!(controller.depth(), 1);
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_side_sheet(
&controller,
move || {
let counter = counter.clone();
glyph_side_sheet("Filters", 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 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_side_sheet(
&controller,
move || {
let counter = counter.clone();
let ctrl = push_ctrl.clone();
glyph_side_sheet("Filters", 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");
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_side_sheet(
&controller,
|| glyph_side_sheet("Filters", 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 it 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);
}
#[test]
fn a_post_push_dismissable_flip_leaves_the_back_press_on_its_pushed_policy() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let flag = Rc::new(Cell::new(true));
let pushed = flag.clone();
show_glyph_side_sheet(
&controller,
move || glyph_side_sheet("Filters", fixed(300.0, 200.0)).dismissable(pushed.get()),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
h.open();
flag.set(false);
h.rebuild();
let rec = h.paint_recorded(ENTER_SETTLED_MS);
assert!(
!rec.rrects.is_empty(),
"the close affordance survives the flip — it is the pushed value that binds"
);
controller.request_back();
h.rebuild();
h.paint(ENTER_SETTLED_MS);
let rec = h.paint_recorded(ENTER_SETTLED_MS + 75.0);
assert!(
scrim_is_mid_fade(&rec),
"the back press still stages the exit, on the policy it was pushed with"
);
h.paint(ENTER_SETTLED_MS + 200.0);
h.flush();
assert_eq!(controller.depth(), 1);
assert_eq!(h.state.results, 1, "exactly one pop");
}
#[test]
fn a_flip_before_the_pages_first_build_still_binds_the_pushed_value() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let flag = Rc::new(Cell::new(true));
let pushed = flag.clone();
show_glyph_side_sheet(
&controller,
move || glyph_side_sheet("Filters", fixed(300.0, 200.0)).dismissable(pushed.get()),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
flag.set(false);
h.open();
let rec = h.paint_recorded(ENTER_SETTLED_MS);
assert!(
!rec.rrects.is_empty(),
"the close affordance is the pushed value's, not the flipped one's"
);
h.event(&ev(PointerPhase::Down, 10.0, 10.0));
h.event(&ev(PointerPhase::Up, 10.0, 10.0));
h.paint(ENTER_SETTLED_MS);
let rec = h.paint_recorded(ENTER_SETTLED_MS + 75.0);
assert!(
scrim_is_mid_fade(&rec),
"the scrim tap staged the exit the pushed policy promises"
);
h.paint(ENTER_SETTLED_MS + 200.0);
h.flush();
assert_eq!(controller.depth(), 1);
assert_eq!(h.state.results, 1, "exactly one pop");
}
#[test]
fn a_flip_before_a_non_dismissable_pages_first_build_still_bars_the_user() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut h = Harness::new(&controller);
let flag = Rc::new(Cell::new(false));
let pushed = flag.clone();
show_glyph_side_sheet(
&controller,
move || glyph_side_sheet("Filters", fixed(300.0, 200.0)).dismissable(pushed.get()),
|state: &mut NavState, _result: PopResult| state.results += 1,
);
flag.set(true);
h.open();
let rec = h.paint_recorded(ENTER_SETTLED_MS);
assert!(
rec.rrects.is_empty(),
"no close affordance: the page was pushed non-dismissable"
);
h.event(&ev(PointerPhase::Down, 10.0, 10.0));
h.event(&ev(PointerPhase::Up, 10.0, 10.0));
h.paint(ENTER_SETTLED_MS);
let rec = h.paint_recorded(ENTER_SETTLED_MS + 75.0);
assert!(
!scrim_is_mid_fade(&rec),
"the scrim tap is barred, on the pushed value"
);
h.flush();
assert_eq!(controller.depth(), 2, "the sheet is still up");
assert_eq!(h.state.results, 0, "and nothing popped");
}
#[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_side_sheet(
&controller,
|| glyph_side_sheet("Filters", 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);
}
#[cfg(feature = "bundled-fonts")]
fn filters(_: &mut ()) -> GlyphSideSheetView<()> {
glyph_side_sheet("Filters", frust::SizedBox::<()>(Some(200.0), Some(100.0)))
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn the_title_paints_in_its_role_face_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(filters, crate::baseline(), area());
assert_eq!(faces, [Face::SpaceMono]);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn the_title_follows_a_live_theme_family_swap() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(filters, area());
assert_eq!(before, [Face::SpaceMono]);
assert_eq!(after, [Face::PlexMono]);
}
}