use std::time::Duration;
use frust::Theme;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, build_child,
rebuild_children, route_event, teardown_child, visit_children,
};
use frust::{AnimationController, Curve, FrameTime, Tween};
use kurbo::{Affine, Point, Size, Vec2};
use peniko::Color;
use crate::motion::GlyphStagger;
pub const GLYPH_REVEAL_GAP: f64 = 6.0;
pub const GLYPH_REVEAL_SLIDE_DP: f64 = 10.0;
const GUIDE_WIDTH: f64 = 1.0;
pub const GLYPH_REVEAL_DASH_LEN: f64 = 6.0;
pub const GLYPH_REVEAL_DASH_GAP: f64 = 4.0;
const FALLBACK_DURATION: Duration = Duration::from_millis(220);
pub const GLYPH_CHEVRON_BOX: f64 = 11.0;
const CHEVRON_ARM: f64 = 3.5;
const CHEVRON_WIDTH: f64 = 1.5;
const GUIDE_BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44); const ACCENT: Color = Color::from_rgb8(0xff, 0xb6, 0x27); const CHEVRON_DIM: Color = Color::from_rgb8(0xa3, 0x9c, 0x88);
struct RevealDriver {
target: f64,
driving_target: f64,
tween: Tween<f64>,
driver: AnimationController,
}
impl RevealDriver {
fn new(initial: f64) -> Self {
Self {
target: initial,
driving_target: initial,
tween: Tween::new(initial, initial),
driver: AnimationController::new(Duration::ZERO),
}
}
fn value(&self) -> f64 {
self.tween.lerp(self.driver.value_clamped())
}
fn set_target(&mut self, target: f64) {
self.target = target;
}
fn snap(&mut self) {
self.tween = Tween::new(self.target, self.target);
self.driving_target = self.target;
self.driver = AnimationController::new(Duration::ZERO);
}
fn advance(&mut self, now: FrameTime, dur: Duration, curve: Curve) -> bool {
if self.target != self.driving_target {
let from = self.value();
self.tween = Tween::new(from, self.target);
self.driving_target = self.target;
self.driver = AnimationController::new(dur).with_curve(curve);
self.driver.forward();
}
self.driver.advance(now)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RevealGuide {
pub indent: f64,
pub pad: f64,
pub accent: bool,
}
impl RevealGuide {
pub const fn new(indent: f64, pad: f64) -> Self {
Self {
indent,
pad,
accent: false,
}
}
pub fn accented(mut self, accent: bool) -> Self {
self.accent = accent;
self
}
}
pub struct GlyphRevealView<State: 'static> {
children: Vec<AnyView<State>>,
expanded: bool,
stagger: bool,
gap: f64,
guide: Option<RevealGuide>,
}
pub fn glyph_reveal<State: 'static>(children: Vec<AnyView<State>>) -> GlyphRevealView<State> {
GlyphRevealView {
children,
expanded: false,
stagger: true,
gap: GLYPH_REVEAL_GAP,
guide: None,
}
}
impl<State: 'static> GlyphRevealView<State> {
pub fn expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
pub fn stagger(mut self, stagger: bool) -> Self {
self.stagger = stagger;
self
}
pub fn gap(mut self, gap: f64) -> Self {
self.gap = gap;
self
}
pub fn guide(mut self, guide: RevealGuide) -> Self {
self.guide = Some(guide);
self
}
}
pub struct GlyphRevealWidget {
pods: Vec<ChildPod>,
expanded: bool,
stagger: bool,
gap: f64,
guide: Option<RevealGuide>,
reveal: RevealDriver,
reveal_value: f64,
content_height: f64,
cascade: AnimationController,
cascade_from: f64,
cascade_value: f64,
cascading: bool,
cascade_frontier: usize,
}
impl<State: 'static> View<State> for GlyphRevealView<State> {
type Element = GlyphRevealWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> GlyphRevealWidget {
let settled = if self.expanded { 1.0 } else { 0.0 };
GlyphRevealWidget {
pods: self
.children
.iter()
.map(|child| build_child(child, ctx))
.collect(),
expanded: self.expanded,
stagger: self.stagger,
gap: self.gap,
guide: self.guide,
reveal: RevealDriver::new(settled),
reveal_value: settled,
content_height: 0.0,
cascade: AnimationController::new(Duration::ZERO),
cascade_from: settled,
cascade_value: settled,
cascading: false,
cascade_frontier: usize::MAX,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut GlyphRevealWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let before = element.pods.len();
let mut flags = rebuild_children(
&prev.children,
&self.children,
&mut element.pods,
ctx,
|view| view,
|_| None,
);
if element.expanded != self.expanded {
element.expanded = self.expanded;
element
.reveal
.set_target(if self.expanded { 1.0 } else { 0.0 });
if self.expanded {
element.arm_cascade(0.0);
} else {
element.settle_cascade();
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else if element.cascading && element.pods.len() != before {
let elapsed = element.cascade_elapsed_ms(before);
element.arm_cascade(elapsed);
}
if element.stagger != self.stagger {
element.stagger = self.stagger;
flags |= ChangeFlags::PAINT;
}
if element.gap != self.gap || element.guide != self.guide {
element.gap = self.gap;
element.guide = self.guide;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
fn teardown(&self, element: &mut GlyphRevealWidget, ctx: &mut BuildCtx<'_>) {
for (view, pod) in self.children.iter().zip(element.pods.iter_mut()) {
teardown_child(view, pod, ctx);
}
}
}
impl GlyphRevealWidget {
fn arm_cascade(&mut self, elapsed_ms: f64) {
let total_ms = GlyphStagger::glyph().total_duration(self.pods.len());
let elapsed = elapsed_ms.clamp(0.0, total_ms);
let remaining = (total_ms - elapsed).max(0.0);
let mut c =
AnimationController::new(Duration::from_millis(remaining.round().max(0.0) as u64))
.with_curve(Curve::Linear);
c.forward();
self.cascade = c;
self.cascade_from = if total_ms > 0.0 {
elapsed / total_ms
} else {
1.0
};
self.cascade_value = self.cascade_from;
self.cascading = true;
}
fn cascade_elapsed_ms(&self, n: usize) -> f64 {
self.cascade_value * GlyphStagger::glyph().total_duration(n)
}
fn settle_cascade(&mut self) {
self.cascade = AnimationController::new(Duration::ZERO);
self.cascade_from = 1.0;
self.cascade_value = 1.0;
self.cascading = false;
}
fn inset(&self) -> f64 {
self.guide.map_or(0.0, |g| g.indent + g.pad)
}
fn input_reach(&self) -> usize {
if self.cascading {
self.cascade_frontier.min(self.pods.len())
} else {
self.pods.len()
}
}
}
fn resolve_inks(theme: Option<&Theme>) -> (Color, Color) {
match theme {
Some(theme) => {
let s = theme.scheme();
(s.outline, s.primary)
}
None => (GUIDE_BORDER, ACCENT),
}
}
fn resolve_timing(theme: Option<&Theme>) -> (Duration, Curve) {
theme
.map(|t| {
(
Duration::from_secs_f64(t.motion.durations.base / 1000.0),
t.motion.easing.spatial,
)
})
.unwrap_or((FALLBACK_DURATION, Curve::EaseInOut))
}
fn stroke_dashed_vline(scene: &mut dyn PaintScene, x: f64, y0: f64, y1: f64, color: Color) {
let step = GLYPH_REVEAL_DASH_LEN + GLYPH_REVEAL_DASH_GAP;
let mut y = y0;
while y < y1 {
let end = (y + GLYPH_REVEAL_DASH_LEN).min(y1);
scene.stroke_line(Point::new(x, y), Point::new(x, end), GUIDE_WIDTH, color);
y += step;
}
}
impl Widget for GlyphRevealWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let bounded = bc.max().width.is_finite();
let inset = self.inset();
let child_max_w = if bounded {
(bc.max().width - inset).max(0.0)
} else {
f64::INFINITY
};
let child_bc = BoxConstraints::new(Size::ZERO, Size::new(child_max_w, f64::INFINITY));
let mut y = 0.0;
let mut natural_w: f64 = 0.0;
for (i, pod) in self.pods.iter_mut().enumerate() {
if i > 0 {
y += self.gap;
}
let size = pod.layout_child(ctx, &child_bc);
pod.set_origin(Point::new(inset, y));
y += size.height;
natural_w = natural_w.max(size.width);
}
self.content_height = y;
let width = if bounded {
bc.max().width
} else {
natural_w + inset
};
bc.constrain(Size::new(width, self.content_height * self.reveal_value))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let (dur, curve) = resolve_timing(theme);
let (border, accent) = resolve_inks(theme);
let before = self.reveal_value;
if reduce_motion {
self.reveal.snap();
} else if self.reveal.advance(ctx.frame_time(), dur, curve) {
ctx.request_layout();
}
self.reveal_value = self.reveal.value().clamp(0.0, 1.0);
if self.reveal_value != before {
ctx.request_layout();
}
if self.cascading {
if self.cascade.advance(ctx.frame_time()) {
ctx.request_frame();
} else {
self.cascading = false;
}
self.cascade_value =
self.cascade_from + (1.0 - self.cascade_from) * self.cascade.value_clamped();
}
let revealed = self.reveal_value * self.content_height;
if revealed <= 0.0 {
return;
}
let o = ctx.origin();
let size = ctx.size();
scene.push_clip(o, Size::new(size.width, revealed));
if let Some(guide) = self.guide {
let ink = if guide.accent { accent } else { border };
stroke_dashed_vline(scene, o.x + guide.indent, o.y, o.y + revealed, ink);
}
let cascading_in = self.stagger && self.expanded;
let spec = GlyphStagger::glyph();
let n = self.pods.len();
let mut frontier = 0usize;
for (i, pod) in self.pods.iter_mut().enumerate() {
let progress = if cascading_in {
spec.item_progress(self.cascade_value, i, n, reduce_motion)
} else {
1.0
};
if progress <= 0.0 {
continue;
}
frontier = i + 1;
let dx = GLYPH_REVEAL_SLIDE_DP * (1.0 - progress);
let settled = progress >= 1.0;
let child_origin = o + pod.origin().to_vec2() + Vec2::new(dx, 0.0);
if !settled {
scene.push_layer(child_origin, pod.size(), progress as f32);
scene.push_transform(Affine::translate((dx, 0.0)));
}
pod.paint_child(ctx, scene);
if !settled {
scene.pop_transform();
scene.pop_layer();
}
}
self.cascade_frontier = frontier;
scene.pop_clip();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let frontier = self.input_reach();
if frontier == self.pods.len()
|| event.is_broadcast()
|| event.is_focus_routed()
|| self.pods.iter().any(|p| p.is_active())
{
return route_event(&mut self.pods, ctx, event);
}
let result = route_event(&mut self.pods[..frontier], ctx, event);
if matches!(event, InputEvent::Pointer(p) if matches!(p.phase, PointerPhase::Down)) {
for pod in &mut self.pods[frontier..] {
if pod.is_focused() {
pod.set_focused(false);
}
}
}
result
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
for pod in &self.pods[..self.input_reach()] {
pod.semantics_child(ctx);
}
}
visit_children!(pods);
}
pub struct GlyphChevronView {
expanded: bool,
}
pub fn glyph_chevron(expanded: bool) -> GlyphChevronView {
GlyphChevronView { expanded }
}
pub struct GlyphChevronWidget {
reveal: RevealDriver,
reveal_value: f64,
}
impl<State: 'static> View<State> for GlyphChevronView {
type Element = GlyphChevronWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> GlyphChevronWidget {
let settled = if self.expanded { 1.0 } else { 0.0 };
GlyphChevronWidget {
reveal: RevealDriver::new(settled),
reveal_value: settled,
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut GlyphChevronWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let target = if self.expanded { 1.0 } else { 0.0 };
if element.reveal.target != target {
element.reveal.set_target(target);
return ChangeFlags::PAINT;
}
ChangeFlags::NONE
}
}
impl Widget for GlyphChevronWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(GLYPH_CHEVRON_BOX, GLYPH_CHEVRON_BOX))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let (dur, curve) = resolve_timing(theme);
let (dim, accent) = match theme {
Some(theme) => {
let s = theme.scheme();
(s.on_surface_variant, s.primary)
}
None => (CHEVRON_DIM, ACCENT),
};
if reduce_motion {
self.reveal.snap();
} else if self.reveal.advance(ctx.frame_time(), dur, curve) {
ctx.request_frame();
}
self.reveal_value = self.reveal.value().clamp(0.0, 1.0);
let size = ctx.size();
let center = Point::new(
ctx.origin().x + size.width / 2.0,
ctx.origin().y + size.height / 2.0,
);
let angle = self.reveal_value * std::f64::consts::FRAC_PI_2;
draw_chevron(
scene,
center,
angle,
mix_ink(dim, accent, self.reveal_value),
);
}
}
fn draw_chevron(scene: &mut dyn PaintScene, center: Point, angle: f64, color: Color) {
let (s, c) = angle.sin_cos();
let rot = |dx: f64, dy: f64| Point::new(center.x + dx * c - dy * s, center.y + dx * s + dy * c);
let top = rot(-CHEVRON_ARM, -CHEVRON_ARM);
let tip = rot(CHEVRON_ARM, 0.0);
let bottom = rot(-CHEVRON_ARM, CHEVRON_ARM);
scene.stroke_line(top, tip, CHEVRON_WIDTH, color);
scene.stroke_line(tip, bottom, CHEVRON_WIDTH, color);
}
fn mix_ink(from: Color, to: Color, t: f64) -> Color {
let t = t.clamp(0.0, 1.0) as f32;
if t <= 0.0 {
return from;
}
if t >= 1.0 {
return to;
}
let (a, b) = (from.components, to.components);
Color::new([
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
a[3] + (b[3] - a[3]) * t,
])
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{PointerButton, PointerEvent, Role, any};
use std::any::Any;
const WINDOW: Size = Size::new(300.0, 400.0);
const HEIGHTS: [f64; 3] = [20.0, 30.0, 40.0];
const NATURAL: f64 = 20.0 + GLYPH_REVEAL_GAP + 30.0 + GLYPH_REVEAL_GAP + 40.0;
#[derive(Debug, Clone, PartialEq)]
enum Op {
Clip(Point, Size),
PopClip,
Layer(Point, Size, f32),
PopLayer,
Transform(Affine),
PopTransform,
Rect(Point, Size),
Line(Point, Point, Color),
}
#[derive(Default)]
struct Recorder {
ops: Vec<Op>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
self.ops.push(Op::Rect(origin, size));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn push_clip(&mut self, origin: Point, size: Size) {
self.ops.push(Op::Clip(origin, size));
}
fn pop_clip(&mut self) {
self.ops.push(Op::PopClip);
}
fn push_layer(&mut self, origin: Point, size: Size, alpha: f32) {
self.ops.push(Op::Layer(origin, size, alpha));
}
fn pop_layer(&mut self) {
self.ops.push(Op::PopLayer);
}
fn push_transform(&mut self, transform: Affine) {
self.ops.push(Op::Transform(transform));
}
fn pop_transform(&mut self) {
self.ops.push(Op::PopTransform);
}
fn stroke_line(&mut self, p0: Point, p1: Point, _width: f64, color: Color) {
self.ops.push(Op::Line(p0, p1, color));
}
}
impl Recorder {
fn clips(&self) -> Vec<(Point, Size)> {
self.ops
.iter()
.filter_map(|op| match op {
Op::Clip(o, s) => Some((*o, *s)),
_ => None,
})
.collect()
}
fn layer_alphas(&self) -> Vec<f32> {
self.ops
.iter()
.filter_map(|op| match op {
Op::Layer(_, _, a) => Some(*a),
_ => None,
})
.collect()
}
fn translations(&self) -> Vec<f64> {
self.ops
.iter()
.filter_map(|op| match op {
Op::Transform(t) => Some(t.translation().x),
_ => None,
})
.collect()
}
fn child_rects(&self) -> Vec<(Point, Size)> {
self.ops
.iter()
.filter_map(|op| match op {
Op::Rect(o, s) => Some((*o, *s)),
_ => None,
})
.collect()
}
fn lines(&self) -> Vec<(Point, Point, Color)> {
self.ops
.iter()
.filter_map(|op| match op {
Op::Line(a, b, c) => Some((*a, *b, *c)),
_ => None,
})
.collect()
}
fn child_progress(&self) -> Vec<f64> {
let mut out = Vec::new();
let mut wrapping = None;
for op in &self.ops {
match op {
Op::Layer(_, _, alpha) => wrapping = Some(*alpha),
Op::Rect(..) => out.push(wrapping.take().map_or(1.0, f64::from)),
_ => {}
}
}
out
}
}
struct Block(Size);
struct BlockWidget(Size);
impl<S: 'static> View<S> for Block {
type Element = BlockWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> BlockWidget {
BlockWidget(self.0)
}
fn rebuild(
&self,
_prev: &Self,
element: &mut BlockWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.0 = self.0;
ChangeFlags::NONE
}
}
impl Widget for BlockWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.0)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
match event {
InputEvent::Pointer(_) => EventResult::Handled,
_ => EventResult::Ignored,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::GenericContainer, |_| {});
}
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn view_of(expanded: bool, heights: &[f64]) -> GlyphRevealView<()> {
glyph_reveal(
heights
.iter()
.map(|h| any(Block(Size::new(120.0, *h))))
.collect(),
)
.expanded(expanded)
}
fn view(expanded: bool) -> GlyphRevealView<()> {
view_of(expanded, &HEIGHTS)
}
fn tap(w: &mut GlyphRevealWidget, at: Point) -> EventResult {
let mut state = ();
let sa: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(sa, Point::ZERO, WINDOW);
w.event(
&mut ctx,
&InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: at,
button: PointerButton::Primary,
}),
)
}
fn build(v: &GlyphRevealView<()>) -> GlyphRevealWidget {
let mut counter = 0u64;
View::<()>::build(v, &mut BuildCtx::new(&mut counter))
}
fn rebuild(prev: &GlyphRevealView<()>, next: &GlyphRevealView<()>, w: &mut GlyphRevealWidget) {
let mut counter = 0u64;
<GlyphRevealView<()> as View<()>>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
}
fn layout(w: &mut GlyphRevealWidget) -> Size {
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(WINDOW))
}
fn paint(
w: &mut GlyphRevealWidget,
size: Size,
ms: f64,
theme: Option<&Theme>,
) -> (Recorder, bool) {
let mut rec = Recorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::for_test(Point::ZERO, size, ft_ms(ms)).with_theme(t as &dyn Any),
None => PaintCtx::for_test(Point::ZERO, size, ft_ms(ms)),
};
w.paint(&mut ctx, &mut rec);
let needs_layout = ctx.needs_layout();
(rec, needs_layout)
}
fn drive_open(w: &mut GlyphRevealWidget, theme: Option<&Theme>) -> Size {
let mut size = layout(w);
let mut t = 0.0;
loop {
let (_, needs_layout) = paint(w, size, t, theme);
if !needs_layout {
break;
}
size = layout(w);
t += 16.0;
assert!(t < 2000.0, "the reveal should settle well under 2s");
}
size
}
#[test]
fn an_already_expanded_first_build_renders_settled_with_no_animation() {
let mut w = build(&view(true));
let size = layout(&mut w);
assert_eq!(
size.height, NATURAL,
"full natural extent on the first pass"
);
let (rec, needs_layout) = paint(&mut w, size, 0.0, None);
assert!(
!needs_layout,
"a settled first build never asks for another layout"
);
assert_eq!(rec.clips(), vec![(Point::ZERO, Size::new(300.0, NATURAL))]);
assert!(
rec.layer_alphas().is_empty(),
"settled children paint with no layer at all"
);
assert!(rec.translations().is_empty(), "and no slide transform");
assert_eq!(rec.child_rects().len(), 3, "every child painted");
}
#[test]
fn a_collapsed_container_reports_no_height_and_paints_nothing() {
let mut w = build(&view(false));
let size = layout(&mut w);
assert_eq!(size.height, 0.0);
assert!(
w.content_height > 0.0,
"children are still measured at full height"
);
let (rec, needs_layout) = paint(&mut w, size, 0.0, None);
assert!(!needs_layout);
assert!(rec.ops.is_empty(), "nothing at all is painted");
}
#[test]
fn expanding_animates_the_height_and_asks_for_a_relayout_each_frame() {
let (closed, open) = (view(false), view(true));
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let mut size = layout(&mut w);
let (_, seed) = paint(&mut w, size, 0.0, None);
assert!(seed, "the seeding frame asks for a relayout");
size = layout(&mut w);
let (rec, animating) = paint(&mut w, size, 60.0, None);
assert!(animating, "still animating at 60ms of a 220ms tween");
let revealed = rec.clips()[0].1.height;
assert!(
revealed > 0.0 && revealed < NATURAL,
"mid-reveal height: {revealed}"
);
let settled = drive_open(&mut w, None);
assert_eq!(settled.height, NATURAL);
let (rec, needs_layout) = paint(&mut w, settled, 1000.0, None);
assert!(!needs_layout, "settled: no further layout requests");
assert_eq!(rec.clips()[0].1.height, NATURAL);
}
#[test]
fn collapsing_reverses_the_tween_and_lands_on_zero() {
let (closed, open) = (view(false), view(true));
let mut w = build(&open);
let size = layout(&mut w);
assert_eq!(size.height, NATURAL);
rebuild(&open, &closed, &mut w);
let mut size = layout(&mut w);
let (_, seed) = paint(&mut w, size, 0.0, None);
assert!(seed);
size = layout(&mut w);
let (rec, animating) = paint(&mut w, size, 60.0, None);
assert!(animating);
let revealed = rec.clips()[0].1.height;
assert!(
revealed > 0.0 && revealed < NATURAL,
"mid-collapse height: {revealed}"
);
assert!(rec.layer_alphas().is_empty());
let mut t = 76.0;
loop {
let (_, needs_layout) = paint(&mut w, size, t, None);
size = layout(&mut w);
if !needs_layout {
break;
}
t += 16.0;
assert!(t < 2000.0);
}
assert_eq!(size.height, 0.0, "a collapse lands exactly on zero");
}
#[test]
fn nothing_paints_below_the_revealed_height_mid_animation() {
let (closed, open) = (view(false), view(true));
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None);
size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 100.0, None);
assert!(matches!(rec.ops.first(), Some(Op::Clip(..))));
assert_eq!(rec.ops.last(), Some(&Op::PopClip));
let revealed = rec.clips()[0].1.height;
assert!(revealed < NATURAL);
for (origin, _) in rec.child_rects() {
assert!(
origin.y < revealed,
"child origin {origin:?} inside the band"
);
}
}
#[test]
fn children_cascade_in_ninety_milliseconds_apart() {
let spec = GlyphStagger::glyph();
assert_eq!(spec.total_duration(3), 330.0);
let (closed, open) = (view(false), view(true));
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None); size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 85.0, None);
assert_eq!(rec.child_rects().len(), 1, "only child 0 has opened");
let (rec, _) = paint(&mut w, size, 95.0, None);
let alphas = rec.layer_alphas();
assert_eq!(rec.child_rects().len(), 2, "child 1 has now opened");
assert!(
alphas[0] > alphas[1] && alphas[1] > 0.0,
"the cascade trails: {alphas:?}"
);
assert_eq!(alphas.len(), 2);
let dx = rec.translations();
assert_eq!(dx.len(), 2);
assert!((dx[0] - GLYPH_REVEAL_SLIDE_DP * (1.0 - alphas[0] as f64)).abs() < 1e-5);
assert!(dx[0] > 0.0 && dx[0] < dx[1] && dx[1] <= GLYPH_REVEAL_SLIDE_DP);
let (rec, _) = paint(&mut w, size, 400.0, None);
assert_eq!(rec.child_rects().len(), 3);
assert!(rec.layer_alphas().is_empty());
assert!(rec.translations().is_empty());
}
#[test]
fn stagger_off_reveals_every_child_at_once() {
let closed = view(false).stagger(false);
let open = view(true).stagger(false);
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None);
size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 16.0, None);
assert_eq!(rec.child_rects().len(), 3, "all three from the first frame");
assert!(rec.layer_alphas().is_empty(), "no per-child fade");
}
#[test]
fn reduce_motion_snaps_the_height_and_reveals_every_child_together() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let (closed, open) = (view(false), view(true));
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let size = layout(&mut w);
let (_, needs_layout) = paint(&mut w, size, 0.0, Some(&theme));
assert_eq!(w.reveal_value, 1.0, "the height snapped, it did not tween");
assert!(needs_layout);
let snapped = layout(&mut w);
assert_eq!(snapped.height, NATURAL);
let (rec, needs_layout) = paint(&mut w, snapped, 16.0, Some(&theme));
assert!(!needs_layout, "a snap asks for exactly one relayout");
assert_eq!(rec.clips()[0].1.height, NATURAL);
let (rec, _) = paint(&mut w, snapped, 100.0, Some(&theme));
let alphas = rec.layer_alphas();
assert_eq!(alphas.len(), 3, "every child fades from the same frame");
assert!(
alphas.iter().all(|a| (a - alphas[0]).abs() < 1e-6),
"simultaneous, not staggered: {alphas:?}"
);
}
#[test]
fn a_mid_cascade_child_arrival_resumes_the_timeline_instead_of_restarting_it() {
let (closed, three) = (view(false), view(true));
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &three, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None); size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 165.0, None);
let before = rec.child_progress();
assert_eq!(before.len(), 2, "child 2 is not in yet: {before:?}");
assert_eq!(before[0], 1.0, "child 0 has settled: {before:?}");
assert!(before[1] > 0.0 && before[1] < 1.0, "mid-fade: {before:?}");
let four = view_of(true, &[20.0, 30.0, 40.0, 50.0]);
rebuild(&three, &four, &mut w);
size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 165.0, None);
let resumed = rec.child_progress();
assert_eq!(
resumed.len(),
before.len(),
"no blank frame, no extra reveal: {before:?} -> {resumed:?}"
);
for (i, (now, was)) in resumed.iter().zip(&before).enumerate() {
assert!(
now >= was,
"child {i} went backwards: {before:?} -> {resumed:?}"
);
}
let (rec, _) = paint(&mut w, size, 250.0, None);
let mid = rec.child_progress();
assert_eq!(mid.len(), 3, "child 2 opened, child 3 has not: {mid:?}");
for (i, (now, was)) in mid.iter().zip(&resumed).enumerate() {
assert!(
now >= was,
"child {i} went backwards: {resumed:?} -> {mid:?}"
);
}
let (rec, _) = paint(&mut w, size, 300.0, None);
let tail = rec.child_progress();
assert_eq!(tail.len(), 4, "child 3 has opened: {tail:?}");
assert!(tail[3] > 0.0 && tail[3] < 1.0, "and it fades: {tail:?}");
let (rec, _) = paint(&mut w, size, 500.0, None);
assert_eq!(rec.child_progress(), vec![1.0; 4]);
}
#[test]
fn a_child_the_cascade_has_not_revealed_yet_is_not_hit_testable() {
let (closed, open) = (view(false), view(true));
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None);
size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 165.0, None);
assert_eq!(rec.child_progress().len(), 2, "child 2 painted nothing");
let in_child_2 = Point::new(60.0, 80.0);
assert_eq!(
tap(&mut w, in_child_2),
EventResult::Ignored,
"blank space must not accept a tap before the row is there"
);
assert_eq!(tap(&mut w, Point::new(60.0, 10.0)), EventResult::Handled);
let (rec, _) = paint(&mut w, size, 400.0, None);
assert_eq!(rec.child_progress(), vec![1.0; 3], "all three settled");
assert_eq!(tap(&mut w, in_child_2), EventResult::Handled);
}
#[test]
fn a_collapsed_container_still_routes_to_its_children() {
let mut w = build(&view(false));
layout(&mut w);
assert_eq!(tap(&mut w, Point::new(60.0, 10.0)), EventResult::Handled);
let closed = view(false).stagger(false);
let open = view(true).stagger(false);
let mut w = build(&closed);
layout(&mut w);
rebuild(&closed, &open, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None);
size = layout(&mut w);
paint(&mut w, size, 16.0, None);
assert_eq!(tap(&mut w, Point::new(60.0, 80.0)), EventResult::Handled);
}
#[test]
fn the_guide_rules_a_dashed_line_at_its_indent_and_insets_the_children() {
let guide = RevealGuide::new(12.0, 8.0);
let mut w = build(&view(true).guide(guide));
let size = layout(&mut w);
for pod in &w.pods {
assert_eq!(pod.origin().x, 20.0, "children inset by indent + pad");
}
let (rec, _) = paint(&mut w, size, 0.0, None);
let lines = rec.lines();
assert!(lines.len() > 1, "a dash run, not one solid stroke");
for (p0, p1, color) in &lines {
assert_eq!(p0.x, 12.0, "the rule sits at the indent");
assert_eq!(p1.x, 12.0);
assert!(p1.y <= NATURAL, "and never past the revealed extent");
assert_eq!(*color, GUIDE_BORDER, "border ink unless accented");
}
assert!((lines[0].1.y - lines[0].0.y - GLYPH_REVEAL_DASH_LEN).abs() < 1e-9);
assert!(
(lines[1].0.y - lines[0].0.y - (GLYPH_REVEAL_DASH_LEN + GLYPH_REVEAL_DASH_GAP)).abs()
< 1e-9
);
}
#[test]
fn an_accent_guide_uses_the_accent_ink_and_stops_at_the_revealed_extent() {
let guide = RevealGuide::new(12.0, 8.0).accented(true);
let (closed, open) = (view(false).guide(guide), view(true).guide(guide));
let mut w = build(&open);
let size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 0.0, None);
assert!(rec.lines().iter().all(|(_, _, c)| *c == ACCENT));
rebuild(&open, &closed, &mut w);
let mut size = layout(&mut w);
paint(&mut w, size, 0.0, None);
size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 100.0, None);
let revealed = rec.clips()[0].1.height;
assert!(revealed < NATURAL);
assert!(rec.lines().iter().all(|(_, p1, _)| p1.y <= revealed));
}
#[test]
fn a_themed_guide_resolves_the_outline_and_primary_roles() {
let theme = crate::baseline();
let mut w = build(&view(true).guide(RevealGuide::new(10.0, 6.0)));
let size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 0.0, Some(&theme));
assert!(
rec.lines()
.iter()
.all(|(_, _, c)| *c == theme.scheme().outline)
);
let mut w = build(&view(true).guide(RevealGuide::new(10.0, 6.0).accented(true)));
let size = layout(&mut w);
let (rec, _) = paint(&mut w, size, 0.0, Some(&theme));
assert!(
rec.lines()
.iter()
.all(|(_, _, c)| *c == theme.scheme().primary)
);
}
#[test]
fn every_child_is_published_to_the_visitor_and_to_semantics() {
let w = build(&view(false));
let mut seen = 0usize;
Widget::visit_children(&w, &mut |_pod| seen += 1);
assert_eq!(seen, 3, "collapsed or not, every child pod is visited");
fn logic(_s: &mut ()) -> GlyphRevealView<()> {
view(false)
}
let mut root: frust_core::RenderRoot<(), GlyphRevealView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.layout(WINDOW);
let update = root.semantics();
let forwarded = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::GenericContainer)
.count();
assert_eq!(
forwarded, 3,
"settled: every child forwarded to semantics, unconditionally"
);
}
#[test]
fn semantics_forwarding_matches_the_event_reachable_prefix_mid_cascade() {
fn logic(expanded: &mut bool) -> GlyphRevealView<bool> {
glyph_reveal::<bool>(
HEIGHTS
.iter()
.map(|h| any(Block(Size::new(120.0, *h))))
.collect(),
)
.expanded(*expanded)
}
let mut root: frust_core::RenderRoot<bool, GlyphRevealView<bool>> =
frust_core::RenderRoot::new();
let mut expanded = false;
root.rebuild(&mut logic, &mut expanded);
root.layout(WINDOW);
expanded = true;
root.rebuild(&mut logic, &mut expanded);
root.layout(WINDOW);
let mut rec = Recorder::default();
root.paint(&mut rec, ft_ms(0.0));
root.layout(WINDOW);
let mut rec = Recorder::default();
root.paint(&mut rec, ft_ms(165.0));
assert_eq!(
rec.child_progress().len(),
2,
"sanity: same frontier as the widget-level hit-test test"
);
let update = root.semantics();
let forwarded = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::GenericContainer)
.count();
assert_eq!(
forwarded, 2,
"semantics forwards only the frontier the last paint revealed, exactly like event"
);
}
#[test]
fn a_rebuilt_child_list_relays_out_and_re_measures() {
let two = glyph_reveal::<()>(vec![
any(Block(Size::new(120.0, 20.0))),
any(Block(Size::new(120.0, 30.0))),
])
.expanded(true);
let three = view(true);
let mut w = build(&two);
assert_eq!(layout(&mut w).height, 20.0 + GLYPH_REVEAL_GAP + 30.0);
rebuild(&two, &three, &mut w);
assert_eq!(layout(&mut w).height, NATURAL);
assert_eq!(w.pods.len(), 3);
}
#[test]
fn a_custom_gap_changes_the_natural_extent() {
let mut w = build(&view(true).gap(0.0));
assert_eq!(layout(&mut w).height, HEIGHTS.iter().sum::<f64>());
}
fn chevron_build(expanded: bool) -> GlyphChevronWidget {
let mut counter = 0u64;
View::<()>::build(&glyph_chevron(expanded), &mut BuildCtx::new(&mut counter))
}
fn chevron_paint(
w: &mut GlyphChevronWidget,
ms: f64,
theme: Option<&Theme>,
) -> (Recorder, bool) {
let size = Size::new(GLYPH_CHEVRON_BOX, GLYPH_CHEVRON_BOX);
let mut rec = Recorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::for_test(Point::ZERO, size, ft_ms(ms)).with_theme(t as &dyn Any),
None => PaintCtx::for_test(Point::ZERO, size, ft_ms(ms)),
};
w.paint(&mut ctx, &mut rec);
let needs_frame = ctx.needs_frame();
(rec, needs_frame)
}
fn tip(rec: &Recorder) -> Point {
rec.lines()[0].1
}
#[test]
fn a_settled_chevron_points_right_when_collapsed_and_down_when_expanded() {
let center = Point::new(GLYPH_CHEVRON_BOX / 2.0, GLYPH_CHEVRON_BOX / 2.0);
let mut collapsed = chevron_build(false);
let (rec, _) = chevron_paint(&mut collapsed, 0.0, None);
let t = tip(&rec);
assert!(
(t.x - (center.x + CHEVRON_ARM)).abs() < 1e-9,
"points right"
);
assert!((t.y - center.y).abs() < 1e-9);
assert_eq!(rec.lines()[0].2, CHEVRON_DIM, "dim ink while collapsed");
let mut expanded = chevron_build(true);
let (rec, needs_frame) = chevron_paint(&mut expanded, 0.0, None);
let t = tip(&rec);
assert!((t.x - center.x).abs() < 1e-9, "points down");
assert!((t.y - (center.y + CHEVRON_ARM)).abs() < 1e-9);
assert_eq!(rec.lines()[0].2, ACCENT, "accent ink while expanded");
assert!(!needs_frame, "a settled chevron asks for no frames");
}
#[test]
fn a_toggled_chevron_rotates_between_the_two_settled_orientations() {
let center = Point::new(GLYPH_CHEVRON_BOX / 2.0, GLYPH_CHEVRON_BOX / 2.0);
let mut w = chevron_build(false);
let mut counter = 0u64;
<GlyphChevronView as View<()>>::rebuild(
&glyph_chevron(true),
&glyph_chevron(false),
&mut w,
&mut BuildCtx::new(&mut counter),
);
let (_, seeding) = chevron_paint(&mut w, 0.0, None);
assert!(seeding, "an animating chevron asks for the next frame");
let (rec, animating) = chevron_paint(&mut w, 60.0, None);
assert!(animating);
let t = tip(&rec);
assert!(
t.x < center.x + CHEVRON_ARM && t.y > center.y,
"mid-rotation tip: {t:?}"
);
let mut ms = 76.0;
loop {
let (rec, more) = chevron_paint(&mut w, ms, None);
if !more {
let t = tip(&rec);
assert!((t.x - center.x).abs() < 1e-9);
assert!((t.y - (center.y + CHEVRON_ARM)).abs() < 1e-9);
break;
}
ms += 16.0;
assert!(ms < 2000.0, "the rotation should settle well under 2s");
}
}
#[test]
fn a_themed_chevron_resolves_the_dim_and_accent_roles() {
let theme = crate::baseline();
let mut collapsed = chevron_build(false);
let (rec, _) = chevron_paint(&mut collapsed, 0.0, Some(&theme));
assert_eq!(rec.lines()[0].2, theme.scheme().on_surface_variant);
let mut expanded = chevron_build(true);
let (rec, _) = chevron_paint(&mut expanded, 0.0, Some(&theme));
assert_eq!(rec.lines()[0].2, theme.scheme().primary);
}
#[test]
fn reduce_motion_snaps_the_chevron_straight_to_its_target() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut w = chevron_build(false);
let mut counter = 0u64;
<GlyphChevronView as View<()>>::rebuild(
&glyph_chevron(true),
&glyph_chevron(false),
&mut w,
&mut BuildCtx::new(&mut counter),
);
let (rec, needs_frame) = chevron_paint(&mut w, 0.0, Some(&theme));
let center = Point::new(GLYPH_CHEVRON_BOX / 2.0, GLYPH_CHEVRON_BOX / 2.0);
let t = tip(&rec);
assert!((t.x - center.x).abs() < 1e-9, "already pointing down");
assert!(!needs_frame, "a snap asks for no further frames");
}
#[test]
fn the_chevron_arm_is_sized_to_the_box_it_rotates_inside() {
let half = GLYPH_CHEVRON_BOX / 2.0;
let center = Point::new(half, half);
let stroke = CHEVRON_WIDTH / 2.0;
for &angle in &[0.0, std::f64::consts::FRAC_PI_2] {
let mut rec = Recorder::default();
draw_chevron(&mut rec, center, angle, CHEVRON_DIM);
assert!(
reach(&rec, center) + stroke < half - 1.0,
"a settled chevron clears the {GLYPH_CHEVRON_BOX}px box"
);
}
let peak = (0..=90)
.map(|deg| {
let mut rec = Recorder::default();
draw_chevron(&mut rec, center, (deg as f64).to_radians(), CHEVRON_DIM);
reach(&rec, center)
})
.fold(0.0_f64, f64::max)
+ stroke;
assert!(
peak < half + 0.25,
"the 45\u{b0} peak stays on the box edge: {peak} against {half}"
);
}
fn reach(rec: &Recorder, center: Point) -> f64 {
rec.lines()
.iter()
.flat_map(|(p0, p1, _)| [*p0, *p1])
.fold(0.0_f64, |acc, p| {
acc.max((p.x - center.x).abs()).max((p.y - center.y).abs())
})
}
#[test]
fn the_chevron_occupies_its_token_sized_box() {
let mut w = chevron_build(false);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 100.0)));
assert_eq!(size, Size::new(GLYPH_CHEVRON_BOX, GLYPH_CHEVRON_BOX));
}
}