use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{Action, Role, Toggled};
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
PaintScene, PointerPhase, SemanticsCtx, View, Widget,
};
use frust::{AnimationController, Brightness, Curve, FrameTime, ShapeScale, Theme, Tween};
use kurbo::{Point, Rect, RoundedRect, Shape, Size, Vec2};
use peniko::{Brush, Color};
use crate::press::presses;
const TRACK_W: f64 = 38.0;
const TRACK_H: f64 = 22.0;
const BORDER_W: f64 = 1.0;
const TOUCH_TARGET: f64 = 44.0;
const KNOB_SIZE: f64 = 16.0;
const KNOB_INSET: f64 = 2.0;
const KNOB_OFFSET: f64 = BORDER_W + KNOB_INSET;
const KNOB_TRAVEL: f64 = 16.0;
const PATH_TOLERANCE: f64 = 0.1;
const KNOB_DURATION: Duration = Duration::from_millis(220);
const KNOB_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const FADE_DURATION: Duration = Duration::from_millis(150);
const FADE_CURVE: Curve = Curve::Cubic(0.16, 1.0, 0.3, 1.0);
const BORDER_OFF_ALPHA: f32 = 0.18;
const TRACK_ON_ALPHA_DARK: f32 = 0.12;
const TRACK_ON_ALPHA_LIGHT: f32 = 0.10;
const BORDER_ON_ALPHA_DARK: f32 = 0.35;
const BORDER_ON_ALPHA_LIGHT: f32 = 0.38;
const KNOB_SHADOW_ALPHA: f32 = 0.2;
const KNOB_SHADOW_BLUR: f64 = 2.0;
const KNOB_SHADOW_DY: f64 = 1.0;
const TOGGLE_TRACK_OFF: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
const TOGGLE_KNOB_OFF: Color = Color::from_rgb8(0x6b, 0x65, 0x56);
const TOGGLE_ACCENT: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
const TOGGLE_BORDER_INK: Color = Color::from_rgb8(0xf2, 0xea, 0xd9);
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn lerp_color(begin: Color, end: Color, t: f64) -> Color {
if t <= 0.0 {
begin
} else if t >= 1.0 {
end
} else {
Tween::new(begin, end).lerp(t)
}
}
struct Progress {
ctrl: AnimationController,
duration: Duration,
curve: Curve,
from: f64,
to: f64,
displayed: f64,
}
impl Progress {
fn at_rest(value: f64, duration: Duration, curve: Curve) -> Self {
Self {
ctrl: AnimationController::new(duration).with_curve(curve),
duration,
curve,
from: value,
to: value,
displayed: value,
}
}
fn retarget(&mut self, to: f64) {
self.from = self.displayed;
self.to = to;
self.ctrl = AnimationController::new(self.duration).with_curve(self.curve);
self.ctrl.forward();
}
fn snap(&mut self) {
self.from = self.to;
self.displayed = self.to;
self.ctrl = AnimationController::new(self.duration).with_curve(self.curve);
}
fn advance(&mut self, now: FrameTime) -> bool {
if self.ctrl.is_animating() {
let animating = self.ctrl.advance(now);
self.displayed = self.from + (self.to - self.from) * self.ctrl.value();
animating
} else {
self.displayed = self.to;
false
}
}
fn value(&self) -> f64 {
self.displayed
}
fn value_clamped(&self) -> f64 {
self.displayed.clamp(0.0, 1.0)
}
}
struct ToggleColors {
track_off: Color,
track_on: Color,
knob_off: Color,
knob_on: Color,
border_off: Color,
border_on: Color,
knob_shadow: Option<Color>,
}
fn resolve_toggle_colors(theme: Option<&Theme>) -> ToggleColors {
match theme {
Some(theme) => {
let scheme = theme.scheme();
let (track_on_alpha, border_on_alpha, knob_off, knob_shadow) = match theme.brightness {
Brightness::Dark => (
TRACK_ON_ALPHA_DARK,
BORDER_ON_ALPHA_DARK,
scheme.on_surface_variant,
None,
),
Brightness::Light => (
TRACK_ON_ALPHA_LIGHT,
BORDER_ON_ALPHA_LIGHT,
scheme.surface_container_lowest,
Some(with_alpha(scheme.shadow, KNOB_SHADOW_ALPHA)),
),
};
ToggleColors {
track_off: scheme.surface_container_highest,
track_on: with_alpha(scheme.primary, track_on_alpha),
knob_off,
knob_on: scheme.primary_container,
border_off: with_alpha(scheme.on_surface, BORDER_OFF_ALPHA),
border_on: with_alpha(scheme.primary, border_on_alpha),
knob_shadow,
}
}
None => ToggleColors {
track_off: TOGGLE_TRACK_OFF,
track_on: with_alpha(TOGGLE_ACCENT, TRACK_ON_ALPHA_DARK),
knob_off: TOGGLE_KNOB_OFF,
knob_on: TOGGLE_ACCENT,
border_off: with_alpha(TOGGLE_BORDER_INK, BORDER_OFF_ALPHA),
border_on: with_alpha(TOGGLE_ACCENT, BORDER_ON_ALPHA_DARK),
knob_shadow: None,
},
}
}
fn track_radius(theme: Option<&Theme>, size: Size) -> f64 {
match theme {
Some(theme) => ShapeScale::resolve(theme.shape.full, size.width, size.height),
None => size.height / 2.0,
}
}
type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
pub struct ToggleView<State: 'static> {
checked: bool,
label: Option<String>,
on_toggle: OnToggle<State>,
}
pub fn toggle<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_toggle: F,
) -> ToggleView<State> {
ToggleView {
checked,
label: None,
on_toggle: Rc::new(on_toggle),
}
}
#[allow(non_snake_case)]
pub fn Toggle<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_toggle: F,
) -> ToggleView<State> {
toggle(checked, on_toggle)
}
impl<State: 'static> ToggleView<State> {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
pub struct ToggleWidget {
checked: bool,
label: Option<String>,
knob: Progress,
fade: Progress,
captured: bool,
on_toggle: frust::authoring::ErasedArgCallback<bool>,
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
impl<State: 'static> View<State> for ToggleView<State> {
type Element = ToggleWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> ToggleWidget {
let rest = if self.checked { 1.0 } else { 0.0 };
ToggleWidget {
checked: self.checked,
label: self.label.clone(),
knob: Progress::at_rest(rest, KNOB_DURATION, KNOB_CURVE),
fade: Progress::at_rest(rest, FADE_DURATION, FADE_CURVE),
captured: false,
on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ToggleWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
let mut flags = ChangeFlags::NONE;
if prev.label != self.label {
element.label = self.label.clone();
flags |= ChangeFlags::PAINT;
}
if prev.checked != self.checked {
element.checked = self.checked;
let target = if self.checked { 1.0 } else { 0.0 };
element.knob.retarget(target);
element.fade.retarget(target);
flags |= ChangeFlags::PAINT;
}
flags
}
}
impl Widget for ToggleWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(
TRACK_W.max(TOUCH_TARGET),
TRACK_H.max(TOUCH_TARGET),
))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let track_size = Size::new(TRACK_W, TRACK_H);
let theme = Theme::from_paint_ctx(ctx);
let colors = resolve_toggle_colors(theme);
let radius = track_radius(theme, track_size);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
if reduce_motion {
self.knob.snap();
self.fade.snap();
} else {
let now = ctx.frame_time();
let knob_running = self.knob.advance(now);
let fade_running = self.fade.advance(now);
if knob_running || fade_running {
ctx.request_frame();
}
}
let box_size = ctx.size();
let origin = ctx.origin()
+ Vec2::new(
((box_size.width - TRACK_W) / 2.0).max(0.0),
((box_size.height - TRACK_H) / 2.0).max(0.0),
);
let fade = self.fade.value_clamped();
scene.fill_rounded_rect(
origin,
track_size,
radius,
lerp_color(colors.track_off, colors.track_on, fade),
);
let inset = BORDER_W / 2.0;
let outline = RoundedRect::from_rect(
Rect::from_origin_size(Point::ORIGIN, track_size).inset(-inset),
(radius - inset).max(0.0),
);
scene.stroke_path(
origin,
&Shape::to_path(&outline, PATH_TOLERANCE),
BORDER_W,
&Brush::Solid(lerp_color(colors.border_off, colors.border_on, fade)),
);
let knob_origin =
origin + Vec2::new(KNOB_OFFSET + KNOB_TRAVEL * self.knob.value(), KNOB_OFFSET);
let knob_size = Size::new(KNOB_SIZE, KNOB_SIZE);
let knob_radius = KNOB_SIZE / 2.0;
if let Some(shadow) = colors.knob_shadow {
scene.draw_shadow(
knob_origin + Vec2::new(0.0, KNOB_SHADOW_DY),
knob_size,
knob_radius,
KNOB_SHADOW_BLUR,
shadow,
);
}
scene.fill_rounded_rect(
knob_origin,
knob_size,
knob_radius,
lerp_color(colors.knob_off, colors.knob_on, fade),
);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.captured = true;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Move => {
if self.captured {
EventResult::Handled
} else {
EventResult::Ignored
}
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
if inside(p.position, ctx.size()) {
(self.on_toggle)(ctx, !self.checked);
}
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Switch, |node| {
node.set_toggled(Toggled::from(self.checked));
node.add_action(Action::Click);
if let Some(label) = &self.label {
node.set_label(label.as_str());
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{PointerButton, PointerEvent};
use std::any::Any;
#[derive(Default)]
struct ToggleState {
last: Option<bool>,
toggles: u32,
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
strokes: Vec<Color>,
shadows: Vec<(Point, Size, f64, f64, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.strokes.push(*c);
}
}
fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
self.shadows.push((o, s, radius, std_dev, color));
}
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
const SLAB: Size = Size::new(TOUCH_TARGET, TOUCH_TARGET);
fn pill_origin() -> Point {
Point::new(
(TOUCH_TARGET - TRACK_W) / 2.0,
(TOUCH_TARGET - TRACK_H) / 2.0,
)
}
fn widget(checked: bool) -> ToggleWidget {
build(&toggle::<ToggleState, _>(
checked,
|s: &mut ToggleState, v| {
s.last = Some(v);
s.toggles += 1;
},
))
}
fn build(view: &ToggleView<ToggleState>) -> ToggleWidget {
let mut counter = 0u64;
View::<ToggleState>::build(view, &mut BuildCtx::new(&mut counter))
}
fn paint_at(w: &mut ToggleWidget, theme: Option<&Theme>, ms: f64) -> (Recorder, bool) {
let mut rec = Recorder::default();
let base = PaintCtx::for_test(Point::ZERO, SLAB, ft_ms(ms));
let mut ctx = match theme {
Some(t) => base.with_theme(t),
None => base,
};
w.paint(&mut ctx, &mut rec);
let again = ctx.needs_frame();
(rec, again)
}
fn paint(w: &mut ToggleWidget, theme: Option<&Theme>) -> Recorder {
paint_at(w, theme, 0.0).0
}
fn knob_x(rec: &Recorder) -> f64 {
rec.rrects[1].0.x - pill_origin().x
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Secondary,
})
}
fn dispatch(w: &mut ToggleWidget, state: &mut ToggleState, event: &InputEvent) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, SLAB);
w.event(&mut ctx, event);
}
#[test]
fn a_secondary_press_never_captures_or_toggles() {
let mut w = widget(false);
let mut state = ToggleState::default();
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Down, 22.0, 22.0),
);
assert!(!w.captured, "no capture for the shell to wedge on");
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Up, 22.0, 22.0),
);
assert_eq!(state.toggles, 0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 22.0, 22.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 22.0, 22.0));
assert_eq!(state.toggles, 1);
assert_eq!(state.last, Some(true));
}
#[test]
fn glyph_dark_theme_paints_the_source_token_table() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut off = widget(false);
let rec = paint(&mut off, Some(&theme));
assert_eq!(rec.rrects.len(), 2, "track + knob only");
assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
assert_eq!(rec.rrects[0].3, Color::from_rgb8(0x27, 0x2d, 0x3d));
assert_eq!(rec.rrects[0].1, Size::new(TRACK_W, TRACK_H));
assert_eq!(rec.rrects[0].0, pill_origin());
assert_eq!(rec.rrects[0].2, TRACK_H / 2.0);
assert_eq!(
rec.strokes[0],
with_alpha(Color::from_rgb8(0xf2, 0xea, 0xd9), 0.18)
);
assert_eq!(rec.rrects[1].3, scheme.on_surface_variant);
assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xa3, 0x9c, 0x88));
assert_ne!(rec.rrects[1].3, TOGGLE_KNOB_OFF, "and it is not `--fg-dim`");
assert_eq!(rec.rrects[1].1, Size::new(KNOB_SIZE, KNOB_SIZE));
assert_eq!(knob_x(&rec), KNOB_OFFSET, "off rests at the pill inset");
assert_eq!(rec.rrects[1].0.y, pill_origin().y + KNOB_OFFSET);
assert!(rec.shadows.is_empty(), "dark authors no knob shadow");
let mut on = widget(true);
let rec = paint(&mut on, Some(&theme));
assert_eq!(
rec.rrects[0].3,
with_alpha(Color::from_rgb8(0xff, 0xb6, 0x27), 0.12)
);
assert_eq!(
rec.strokes[0],
with_alpha(Color::from_rgb8(0xff, 0xb6, 0x27), 0.35)
);
assert_eq!(rec.rrects[1].3, scheme.primary_container);
assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xb6, 0x27));
assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
assert!(rec.shadows.is_empty());
}
#[test]
fn glyph_light_theme_paints_a_white_knob_with_a_lift_shadow() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let scheme = theme.scheme();
let mut off = widget(false);
let rec = paint(&mut off, Some(&theme));
assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
assert_eq!(rec.rrects[0].3, Color::from_rgb8(0xe8, 0xe1, 0xd0));
assert_eq!(
rec.strokes[0],
with_alpha(Color::from_rgb8(0x22, 0x1d, 0x12), 0.18)
);
assert_eq!(rec.rrects[1].3, scheme.surface_container_lowest);
assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xff, 0xff));
assert_eq!(knob_x(&rec), KNOB_OFFSET);
assert_eq!(rec.shadows.len(), 1, "light lifts the knob");
let (shadow_origin, shadow_size, _, blur, shadow_color) = rec.shadows[0];
assert_eq!(
shadow_origin,
pill_origin() + Vec2::new(KNOB_OFFSET, KNOB_OFFSET + KNOB_SHADOW_DY)
);
assert_eq!(shadow_size, Size::new(KNOB_SIZE, KNOB_SIZE));
assert_eq!(blur, KNOB_SHADOW_BLUR);
assert_eq!(shadow_color, with_alpha(scheme.shadow, KNOB_SHADOW_ALPHA));
assert_eq!(
shadow_color,
with_alpha(Color::from_rgb8(0x22, 0x1d, 0x12), KNOB_SHADOW_ALPHA)
);
let mut on = widget(true);
let rec = paint(&mut on, Some(&theme));
assert_eq!(
rec.rrects[0].3,
with_alpha(Color::from_rgb8(0xa3, 0x65, 0x0a), 0.10)
);
assert_eq!(
rec.strokes[0],
with_alpha(Color::from_rgb8(0xa3, 0x65, 0x0a), 0.38)
);
assert_eq!(rec.rrects[1].3, scheme.primary_container);
assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xb6, 0x27));
assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
assert_eq!(rec.shadows.len(), 1, "the lift survives the checked state");
}
#[test]
fn unthemed_paint_uses_the_dark_fallback_constants() {
let mut off = widget(false);
let rec = paint(&mut off, None);
assert_eq!(rec.rrects[0].3, TOGGLE_TRACK_OFF);
assert_eq!(rec.rrects[0].2, TRACK_H / 2.0, "pill radius");
assert_eq!(rec.rrects[1].3, TOGGLE_KNOB_OFF, "the exact `fg-dim` hex");
assert_eq!(
rec.strokes[0],
with_alpha(TOGGLE_BORDER_INK, BORDER_OFF_ALPHA)
);
assert!(rec.shadows.is_empty());
let mut on = widget(true);
let rec = paint(&mut on, None);
assert_eq!(
rec.rrects[0].3,
with_alpha(TOGGLE_ACCENT, TRACK_ON_ALPHA_DARK)
);
assert_eq!(rec.rrects[1].3, TOGGLE_ACCENT);
assert_eq!(
rec.strokes[0],
with_alpha(TOGGLE_ACCENT, BORDER_ON_ALPHA_DARK)
);
}
#[test]
fn layout_requests_a_touch_slab_around_the_pill() {
let mut w = widget(false);
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(size, SLAB);
assert!(
size.width > TRACK_W && size.height > TRACK_H,
"the interactive box is larger than the 38×22 pill it paints"
);
assert!(
size.width >= TOUCH_TARGET && size.height >= TOUCH_TARGET,
"and clears WCAG 2.2 / iOS's minimum on both axes (4dp under \
Android's 48dp, accepted — see `TOUCH_TARGET`'s doc)"
);
}
#[test]
fn paint_centers_the_fixed_pill_in_whatever_box_it_is_given() {
let mut w = widget(true);
let mut rec = Recorder::default();
let box_size = Size::new(80.0, 60.0);
let mut ctx = PaintCtx::for_test(Point::new(10.0, 20.0), box_size, ft_ms(0.0));
w.paint(&mut ctx, &mut rec);
let expected = Point::new(
10.0 + (box_size.width - TRACK_W) / 2.0,
20.0 + (box_size.height - TRACK_H) / 2.0,
);
assert_eq!(rec.rrects[0].0, expected);
assert_eq!(rec.rrects[0].1, Size::new(TRACK_W, TRACK_H), "never scaled");
assert_eq!(
rec.rrects[1].0,
expected + Vec2::new(KNOB_OFFSET + KNOB_TRAVEL, KNOB_OFFSET)
);
}
#[test]
fn the_overshooting_knob_stays_inside_the_slab() {
let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
let mut w = build(&prev);
let next = toggle::<ToggleState, _>(true, |_s, _v| {});
let mut counter = 0u64;
View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
paint_at(&mut w, None, 0.0);
let mut peak_right = f64::MIN;
for ms in (10..=210).step_by(10) {
let (rec, _) = paint_at(&mut w, None, ms as f64);
peak_right = peak_right.max(rec.rrects[1].0.x + KNOB_SIZE);
}
let rest_right = pill_origin().x + KNOB_OFFSET + KNOB_TRAVEL + KNOB_SIZE;
assert!(
peak_right > rest_right,
"the overshoot is real ({peak_right} vs {rest_right})"
);
assert!(
peak_right <= pill_origin().x + TRACK_W - BORDER_W,
"yet the knob never escapes the track's inner edge ({peak_right})"
);
assert!(
peak_right <= SLAB.width,
"nor the slab ({peak_right} vs {})",
SLAB.width
);
}
#[test]
fn a_tap_in_the_slab_margin_outside_the_pill_still_toggles() {
let mut w = widget(false);
let mut state = ToggleState::default();
let (x, y) = (TOUCH_TARGET - 1.0, TOUCH_TARGET - 1.0);
assert!(
x > pill_origin().x + TRACK_W && y > pill_origin().y + TRACK_H,
"the probe really is outside the painted pill"
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, x, y));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, x, y));
assert_eq!(state.last, Some(true));
assert_eq!(state.toggles, 1);
}
#[test]
fn knob_travels_the_source_distance_and_settles() {
let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
let mut w = build(&prev);
let (rec, again) = paint_at(&mut w, None, 0.0);
assert_eq!(knob_x(&rec), KNOB_OFFSET, "off rests at the inset");
assert!(!again, "a resting toggle requests no frame");
let next = toggle::<ToggleState, _>(true, |_s, _v| {});
let mut counter = 0u64;
let flags =
View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(flags.needs_paint());
assert!(!flags.needs_layout(), "the track never resizes");
let (rec, again) = paint_at(&mut w, None, 0.0);
assert_eq!(knob_x(&rec), KNOB_OFFSET);
assert!(again, "an in-flight knob owes another frame");
let settled_x = KNOB_OFFSET + KNOB_TRAVEL;
let mut peak_x = f64::MIN;
for ms in (10..=210).step_by(10) {
let (rec, again) = paint_at(&mut w, None, ms as f64);
peak_x = peak_x.max(knob_x(&rec));
assert!(again, "still animating at {ms}ms");
}
assert!(
peak_x > settled_x,
"the spatial curve overshoots its rest spot ({peak_x} vs {settled_x})"
);
let (rec, again) = paint_at(&mut w, None, 400.0);
assert_eq!(knob_x(&rec), settled_x, "lands exactly 16px across");
assert!(!again, "a settled toggle stops requesting frames");
assert_eq!(rec.rrects[1].3, TOGGLE_ACCENT, "and the fade completed");
}
#[test]
fn a_reversal_travels_back_to_the_off_inset() {
let prev = toggle::<ToggleState, _>(true, |_s, _v| {});
let mut w = build(&prev);
let next = toggle::<ToggleState, _>(false, |_s, _v| {});
let mut counter = 0u64;
View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
let (_, again) = paint_at(&mut w, None, 0.0);
assert!(again);
let (rec, again) = paint_at(&mut w, None, 400.0);
assert_eq!(knob_x(&rec), KNOB_OFFSET);
assert!(!again);
assert_eq!(rec.rrects[1].3, TOGGLE_KNOB_OFF);
}
#[test]
fn reduce_motion_paints_the_final_state_at_once_and_requests_no_frame() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
let mut w = build(&prev);
let next = toggle::<ToggleState, _>(true, |_s, _v| {});
let mut counter = 0u64;
View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
let (rec, again) = paint_at(&mut w, Some(&theme), 0.0);
assert!(!again, "reduce_motion owes no frame");
assert_eq!(
knob_x(&rec),
KNOB_OFFSET + KNOB_TRAVEL,
"the knob lands immediately"
);
assert_eq!(
rec.rrects[1].3,
theme.scheme().primary_container,
"and the color lane is done too"
);
let (rec, again) = paint_at(&mut w, Some(&theme), 400.0);
assert!(!again);
assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
}
#[test]
fn reduce_motion_snaps_a_reversal_back_as_well() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let prev = toggle::<ToggleState, _>(true, |_s, _v| {});
let mut w = build(&prev);
let next = toggle::<ToggleState, _>(false, |_s, _v| {});
let mut counter = 0u64;
View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
let (rec, again) = paint_at(&mut w, Some(&theme), 0.0);
assert!(!again);
assert_eq!(knob_x(&rec), KNOB_OFFSET);
assert_eq!(rec.rrects[1].3, theme.scheme().on_surface_variant);
}
#[test]
fn release_inside_reports_the_requested_value_once() {
let mut w = widget(false);
let mut state = ToggleState::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
assert_eq!(state.last, Some(true));
assert_eq!(state.toggles, 1);
assert!(!w.checked, "a controlled toggle never self-mutates");
let mut w = widget(true);
let mut state = ToggleState::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
assert_eq!(state.last, Some(false));
assert!(w.checked, "still checked until the app rebuilds it");
}
#[test]
fn release_outside_does_not_fire() {
let mut w = widget(false);
let mut state = ToggleState::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 11.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 11.0));
assert_eq!(state.toggles, 0);
assert!(!w.captured, "the release disarms either way");
}
#[test]
fn cancel_disarms_the_press() {
let mut w = widget(false);
let mut state = ToggleState::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 8.0, 11.0));
assert!(!w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
assert_eq!(state.toggles, 0);
}
#[test]
fn an_unarmed_move_is_ignored() {
let mut w = widget(false);
let mut state = ToggleState::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, SLAB);
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 8.0, 11.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
}
#[test]
fn semantics_reports_a_labelled_switch_with_its_toggled_state() {
fn logic(_s: &mut ToggleState) -> ToggleView<ToggleState> {
toggle::<ToggleState, _>(true, |_s, _v| {}).label("auto-reconnect")
}
let mut root: frust_core::RenderRoot<ToggleState, ToggleView<ToggleState>> =
frust_core::RenderRoot::new();
let mut state = ToggleState::default();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 200.0));
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Switch)
.expect("the toggle contributes a Role::Switch node");
assert_eq!(node.toggled(), Some(Toggled::True));
assert!(node.supports_action(Action::Click));
assert_eq!(node.label(), Some("auto-reconnect"));
let bounds = node.bounds().expect("the switch node has bounds");
assert_eq!(
(bounds.x1 - bounds.x0, bounds.y1 - bounds.y0),
(TOUCH_TARGET, TOUCH_TARGET)
);
}
#[test]
fn semantics_mirrors_an_unchecked_unlabelled_toggle() {
fn logic(_s: &mut ToggleState) -> ToggleView<ToggleState> {
toggle::<ToggleState, _>(false, |_s, _v| {})
}
let mut root: frust_core::RenderRoot<ToggleState, ToggleView<ToggleState>> =
frust_core::RenderRoot::new();
let mut state = ToggleState::default();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 200.0));
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Switch)
.expect("the toggle contributes a Role::Switch node");
assert_eq!(node.toggled(), Some(Toggled::False));
assert_eq!(node.label(), None);
}
#[test]
fn a_label_change_alone_is_a_republish() {
let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
let mut w = build(&prev);
assert_eq!(w.label, None);
let next = toggle::<ToggleState, _>(false, |_s, _v| {}).label("haptics");
let mut counter = 0u64;
let flags =
View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.label.as_deref(), Some("haptics"));
assert!(
flags.needs_paint(),
"the semantics dirty gate rides `PAINT`"
);
}
}