Skip to main content

frust_widgets/
slider.rs

1//! The `Slider` interactive widget: a horizontal track with a thumb
2//! that reports a `0.0..=1.0` value as it is dragged.
3//!
4//! [`slider`] produces a [`SliderView`] carrying the current `value` and an
5//! `on_change` closure. A `Down` on the track captures the pointer and reports
6//! the value at that x-position; each captured `Move` reports the updated value
7//! (continuous drag); `Up`/`Cancel` release. Like [`crate::Checkbox`] it is not
8//! its own source of truth — it fires `on_change` and the next rebuild feeds the
9//! new value back in. Horizontal only for v1.
10//!
11//! # Design-language branch
12//!
13//! `paint` reads [`Theme::design_language`] and re-skins the knob only when it
14//! is [`DesignLanguage::Cupertino`] — groove/fill geometry, colors, event
15//! handling, and the entire `Material3`/unthemed path are **untouched**: a
16//! Material-tagged or unthemed paint runs the exact same sequence as before
17//! this branch was added (same `fill_rounded_rect` call count/order/values),
18//! so Material rendering stays byte-identical. The kit's mined data
19//! (`kit-colors-type-metrics.json`) has **no `Sliders` size record at all**
20//! (only shadow recipes for the knob in `glass-recipes.json`, with no
21//! width/height fields). Per the same evidence rule this repo applies to any
22//! unsourced platform metric,
23//! [`CUPERTINO_THUMB`] is therefore **community-approximate**, not kit-cited
24//! (see its doc comment); the groove thickness ([`TRACK_H`]) is shared with
25//! the Material path unchanged, since there is no evidence either way to
26//! diverge it. The knob's reflective treatment (drop shadow + specular
27//! gradient) reads the theme's own `glass.control` tier (buttons/toggles/
28//! sliders — the "control" glass tier covers interactive controls generally,
29//! see `frust-theme/src/glass.rs`'s module docs), so the recipe is whatever
30//! the active design system authored, never a value named here. iOS 26.2's
31//! "Liquid Glass
32//! slider" claims (a user-facing OS tint-adjustment control) are **not**
33//! what this branch re-skins — this is Frust's `Slider` widget, a different
34//! control; this widget ships a visual re-skin only, no behavior changes.
35
36use std::rc::Rc;
37
38use frust_core::accesskit::Role;
39use frust_core::{
40    BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
41    PaintScene, PointerPhase, SemanticsCtx, View, Widget,
42};
43use frust_theme::{DesignLanguage, GlassMaterial, GlassScale, Theme};
44use kurbo::{Point, Size};
45use peniko::{Brush, Color, Gradient};
46
47use crate::authoring::presses;
48
49/// Slider control height, in logical px.
50const HEIGHT: f64 = 24.0;
51/// Default track width when the incoming constraints are unbounded.
52const DEFAULT_WIDTH: f64 = 200.0;
53/// Track (groove) thickness, in logical px. Shared by both design-language
54/// paths (see the module docs' Design-language branch section — no kit or
55/// community evidence to diverge it for Cupertino).
56const TRACK_H: f64 = 4.0;
57/// Thumb diameter, in logical px — the Material path only (see [`THUMB_FILL`]
58/// for its color; [`CUPERTINO_THUMB`] is the Cupertino-branch equivalent).
59const THUMB: f64 = 18.0;
60/// Cupertino knob diameter, in logical px.
61///
62/// **Community-approximate**, not kit-cited: the kit's mined data has no
63/// `Sliders` size record at all (see the module docs) — 28pt is the
64/// widely-cited default `UISlider` thumb diameter community reimplementations
65/// converge on, distinctly larger than the Material path's 18pt (M3's own
66/// slider spec).
67const CUPERTINO_THUMB: f64 = 28.0;
68/// The Cupertino knob's fill — plain white, mirroring
69/// [`crate::cupertino::switch`]'s knob and `UISlider`'s stock white-circle
70/// thumb (its `minimumTrackTintColor`/active-fill carries the tint instead;
71/// see [`resolve_colors`]'s `fill`, reused unchanged for the active track on
72/// both design-language paths).
73const CUPERTINO_THUMB_FILL: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
74/// Peak alpha of the Cupertino knob's specular-highlight gradient at its
75/// center — see [`crate::cupertino::switch`]'s constant of the same name and
76/// role (this module mirrors that treatment exactly).
77const SPECULAR_PEAK_SCALE: f32 = 1.8;
78/// Unfilled (inactive) track color (unthemed fallback; a theme resolves this
79/// from `colors.surface_container_highest`, per the M3 slider spec).
80const TRACK: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
81/// Filled (active) track color (unthemed fallback; a theme resolves this from
82/// `colors.primary`).
83const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
84/// Thumb color (unthemed fallback; a theme resolves this from `colors.primary`).
85const THUMB_FILL: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
86
87/// The resolved slider paint colors: `(inactive_track, active_track, thumb)`.
88/// Themed per the Material 3 slider spec: the inactive track is
89/// `surface_container_highest`, the active track and thumb are `primary`.
90/// Unthemed: the [`TRACK`]/[`FILL`]/[`THUMB_FILL`] constants exactly, so a
91/// pre-theme app renders unchanged.
92fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
93    match theme {
94        Some(theme) => {
95            let scheme = theme.scheme();
96            (
97                scheme.surface_container_highest,
98                scheme.primary,
99                scheme.primary,
100            )
101        }
102        None => (TRACK, FILL, THUMB_FILL),
103    }
104}
105
106/// `true` when `theme` is tagged [`DesignLanguage::Cupertino`] — the sole
107/// gate on the Cupertino knob branch (see the module docs). An unthemed paint
108/// (`None`) and a `Material3`-tagged one both return `false`, running the
109/// Material path unchanged.
110fn is_cupertino(theme: Option<&Theme>) -> bool {
111    theme.map(|t| t.design_language) == Some(DesignLanguage::Cupertino)
112}
113
114/// The `control` glass tier (buttons/toggles/sliders) the Cupertino knob's
115/// shadow/highlight reads: `theme.glass.control`.
116///
117/// The unthemed arm is **unreachable by construction** — the sole call site
118/// sits inside the `is_cupertino(theme)` guard in `paint`, and
119/// `is_cupertino(None)` is `false`, so `theme` is always `Some` here. It
120/// therefore resolves to the language-free [`GlassScale::opaque_material`]
121/// tier rather than naming a design language's glass recipe: a fallback no
122/// paint pass can observe must not pin this baseline widget to a catalog's
123/// token table.
124fn resolve_control_glass(theme: Option<&Theme>) -> GlassMaterial {
125    theme
126        .map(|t| t.glass.control.clone())
127        .unwrap_or_else(|| GlassScale::opaque_material().control)
128}
129
130/// Return `color` with its alpha channel replaced by `alpha` (mirrors the
131/// per-module helper of the same shape used across `frust-widgets`).
132fn with_alpha(color: Color, alpha: f32) -> Color {
133    let c = color.components;
134    Color::new([c[0], c[1], c[2], alpha])
135}
136
137/// A view-held, typed change callback (erased on build).
138type OnChange<State> = Rc<dyn Fn(&mut State, f64)>;
139
140/// A declarative slider. See the [module docs](self).
141pub struct SliderView<State: 'static> {
142    value: f64,
143    on_change: OnChange<State>,
144}
145
146/// Create a slider at `value` (clamped to `0.0..=1.0`) that fires
147/// `on_change(state, new_value)` as it is dragged.
148pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
149    value: f64,
150    on_change: F,
151) -> SliderView<State> {
152    SliderView {
153        value: value.clamp(0.0, 1.0),
154        on_change: Rc::new(on_change),
155    }
156}
157
158/// PascalCase alias for [`slider`].
159#[allow(non_snake_case)]
160pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
161    value: f64,
162    on_change: F,
163) -> SliderView<State> {
164    slider(value, on_change)
165}
166
167/// The retained widget for a [`SliderView`].
168pub struct SliderWidget {
169    value: f64,
170    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
171    /// Gates `Move` so a hover `Move` (no prior press) never fires `on_change`;
172    /// a `Down` still jumps to and reports the tapped value regardless.
173    captured: bool,
174    on_change: crate::authoring::ErasedArgCallback<f64>,
175}
176
177/// Map a widget-local x (in `0..=width`) to a `0.0..=1.0` value, clamped.
178fn value_from_x(x: f64, width: f64) -> f64 {
179    if width <= 0.0 {
180        0.0
181    } else {
182        (x / width).clamp(0.0, 1.0)
183    }
184}
185
186impl<State: 'static> View<State> for SliderView<State> {
187    type Element = SliderWidget;
188
189    fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
190        SliderWidget {
191            value: self.value,
192            captured: false,
193            on_change: crate::authoring::erase_callback_arg(&self.on_change),
194        }
195    }
196
197    fn rebuild(
198        &self,
199        prev: &Self,
200        element: &mut SliderWidget,
201        _ctx: &mut BuildCtx<'_>,
202    ) -> ChangeFlags {
203        element.on_change = crate::authoring::erase_callback_arg(&self.on_change);
204        if prev.value != self.value {
205            element.value = self.value;
206            ChangeFlags::PAINT
207        } else {
208            ChangeFlags::NONE
209        }
210    }
211}
212
213impl Widget for SliderWidget {
214    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
215        let width = if bc.max().width.is_finite() {
216            bc.max().width
217        } else {
218            DEFAULT_WIDTH
219        };
220        bc.constrain(Size::new(width, HEIGHT))
221    }
222
223    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
224        let theme = Theme::from_paint_ctx(ctx);
225        let (track, fill, thumb_fill) = resolve_colors(theme);
226        let o = ctx.origin();
227        let w = ctx.size().width;
228        let mid_y = o.y + ctx.size().height / 2.0;
229        // Groove.
230        scene.fill_rounded_rect(
231            Point::new(o.x, mid_y - TRACK_H / 2.0),
232            Size::new(w, TRACK_H),
233            TRACK_H / 2.0,
234            track,
235        );
236        // Filled portion up to the thumb.
237        let thumb_x = o.x + self.value.clamp(0.0, 1.0) * w;
238        scene.fill_rounded_rect(
239            Point::new(o.x, mid_y - TRACK_H / 2.0),
240            Size::new((thumb_x - o.x).max(0.0), TRACK_H),
241            TRACK_H / 2.0,
242            fill,
243        );
244
245        if is_cupertino(theme) {
246            // Cupertino re-skin: larger reflective knob — see
247            // the module docs' Design-language branch section.
248            let diam = CUPERTINO_THUMB;
249            let thumb_origin = Point::new(thumb_x - diam / 2.0, mid_y - diam / 2.0);
250            let thumb_size = Size::new(diam, diam);
251            let glass_control = resolve_control_glass(theme);
252            scene.draw_shadow(
253                Point::new(
254                    thumb_origin.x,
255                    thumb_origin.y + glass_control.shadow.y_offset,
256                ),
257                thumb_size,
258                diam / 2.0,
259                glass_control.shadow.blur_std_dev,
260                with_alpha(Color::BLACK, glass_control.shadow.color_alpha),
261            );
262            scene.fill_rounded_rect(thumb_origin, thumb_size, diam / 2.0, CUPERTINO_THUMB_FILL);
263            let highlight_center = Point::new(thumb_x - diam * 0.18, mid_y - diam * 0.28);
264            let peak_alpha = (glass_control.hairline_alpha * SPECULAR_PEAK_SCALE).min(1.0);
265            let highlight = Gradient::new_radial(highlight_center, (diam * 0.65) as f32)
266                .with_stops([
267                    (0.0f32, with_alpha(CUPERTINO_THUMB_FILL, peak_alpha)),
268                    (1.0f32, with_alpha(CUPERTINO_THUMB_FILL, 0.0)),
269                ]);
270            scene.fill_rounded_rect_brush(
271                thumb_origin,
272                thumb_size,
273                diam / 2.0,
274                &Brush::Gradient(highlight),
275            );
276        } else {
277            // Material path (and unthemed): byte-identical to the pre-Cupertino
278            // behavior — thumb (rounded-rect stand-in for a circle in v1).
279            scene.fill_rounded_rect(
280                Point::new(thumb_x - THUMB / 2.0, mid_y - THUMB / 2.0),
281                Size::new(THUMB, THUMB),
282                THUMB / 2.0,
283                thumb_fill,
284            );
285        }
286    }
287
288    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
289        let InputEvent::Pointer(p) = event else {
290            return EventResult::Ignored;
291        };
292        match p.phase {
293            PointerPhase::Down => {
294                if !presses(p) {
295                    return EventResult::Ignored;
296                }
297                self.captured = true;
298                ctx.capture_pointer();
299                let v = value_from_x(p.position.x, ctx.size().width);
300                (self.on_change)(ctx, v);
301                ctx.request_redraw();
302                EventResult::Handled
303            }
304            PointerPhase::Move => {
305                // A hover `Move` (no prior press) never reports a value.
306                if !self.captured {
307                    return EventResult::Ignored;
308                }
309                let v = value_from_x(p.position.x, ctx.size().width);
310                (self.on_change)(ctx, v);
311                ctx.request_redraw();
312                EventResult::Handled
313            }
314            PointerPhase::Up | PointerPhase::Cancel => {
315                if !self.captured {
316                    return EventResult::Ignored;
317                }
318                self.captured = false;
319                ctx.request_redraw();
320                EventResult::Handled
321            }
322        }
323    }
324
325    fn semantics(&self, ctx: &mut SemanticsCtx) {
326        // A Slider node exposing its normalized `0.0..=1.0` value and range.
327        ctx.push_node(Role::Slider, |node| {
328            node.set_numeric_value(self.value);
329            node.set_min_numeric_value(0.0);
330            node.set_max_numeric_value(1.0);
331        });
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use std::any::Any;
339
340    #[derive(Default)]
341    struct Val {
342        value: f64,
343        changes: u32,
344    }
345
346    fn widget(value: f64) -> SliderWidget {
347        let view = slider::<Val, _>(value, |s: &mut Val, v: f64| {
348            s.value = v;
349            s.changes += 1;
350        });
351        let mut counter = 0u64;
352        View::<Val>::build(&view, &mut BuildCtx::new(&mut counter))
353    }
354
355    fn ev(phase: PointerPhase, x: f64) -> InputEvent {
356        InputEvent::Pointer(frust_core::PointerEvent {
357            phase,
358            position: Point::new(x, 12.0),
359            button: frust_core::PointerButton::Primary,
360        })
361    }
362
363    /// The same event on the secondary (right) button.
364    fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
365        InputEvent::Pointer(frust_core::PointerEvent {
366            phase,
367            position: Point::new(x, 12.0),
368            button: frust_core::PointerButton::Secondary,
369        })
370    }
371
372    fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) {
373        let state_any: &mut dyn Any = state;
374        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
375        w.event(&mut ctx, event);
376    }
377
378    #[test]
379    fn a_secondary_press_never_captures_or_reports_a_value() {
380        let mut w = widget(0.0);
381        let mut state = Val::default();
382        dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
383        assert!(!w.captured, "no capture opened");
384        assert_eq!(state.changes, 0, "and no value reported");
385        // A move after it is an ordinary uncaptured pass, not a drag.
386        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
387        assert_eq!(state.changes, 0);
388
389        // The primary press still reports.
390        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
391        assert!(w.captured);
392        assert_eq!(state.value, 0.25);
393    }
394
395    #[test]
396    fn value_math_maps_position_to_fraction() {
397        assert_eq!(value_from_x(0.0, 200.0), 0.0);
398        assert_eq!(value_from_x(100.0, 200.0), 0.5);
399        assert_eq!(value_from_x(200.0, 200.0), 1.0);
400        // Clamps outside the track.
401        assert_eq!(value_from_x(-40.0, 200.0), 0.0);
402        assert_eq!(value_from_x(9000.0, 200.0), 1.0);
403        // Degenerate zero-width track.
404        assert_eq!(value_from_x(10.0, 0.0), 0.0);
405    }
406
407    #[test]
408    fn down_reports_value_at_position() {
409        let mut w = widget(0.0);
410        let mut state = Val::default();
411        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
412        assert_eq!(state.value, 0.25);
413        assert_eq!(state.changes, 1);
414    }
415
416    #[test]
417    fn drag_reports_continuously_and_clamps() {
418        let mut w = widget(0.0);
419        let mut state = Val::default();
420        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
421        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 300.0)); // past the end
422        assert_eq!(state.value, 1.0);
423        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, -20.0)); // before the start
424        assert_eq!(state.value, 0.0);
425        assert_eq!(state.changes, 3);
426        // Up does not report a value.
427        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 0.0));
428        assert_eq!(state.changes, 3);
429    }
430
431    #[test]
432    fn hover_move_without_down_is_ignored_noop() {
433        let mut w = widget(0.3);
434        let mut state = Val::default();
435        let state_any: &mut dyn Any = &mut state;
436        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
437        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
438        assert!(matches!(result, EventResult::Ignored));
439        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
440        assert_eq!(state.changes, 0, "hover must not report a value");
441    }
442
443    #[test]
444    fn cancel_clears_captured_state() {
445        let mut w = widget(0.0);
446        let mut state = Val::default();
447        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
448        assert!(w.captured);
449        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
450        assert!(!w.captured, "Cancel disarms the drag");
451        // A follow-up hover Move must not report.
452        let before = state.changes;
453        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
454        assert_eq!(state.changes, before, "hover after Cancel does not report");
455    }
456
457    /// Records rounded-rect fill colors in paint order (track, fill, thumb).
458    /// `shadows`/`brushes` separately record the Cupertino knob's reflective-
459    /// treatment calls (`draw_shadow`/`fill_rounded_rect_brush`), which never
460    /// fire on the Material path (see `material_and_unthemed_paint_have_no_reflective_treatment`).
461    #[derive(Default)]
462    struct TrackRecorder {
463        rrects: Vec<Color>,
464        shadows: Vec<(Point, Size, f64, f64, Color)>,
465        brushes: Vec<(Point, Size, f64, Brush)>,
466    }
467
468    impl PaintScene for TrackRecorder {
469        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
470        fn draw_text(&mut self, _o: Point, _t: &str) {}
471        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
472            self.rrects.push(color);
473        }
474        fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
475            self.shadows.push((o, s, radius, std_dev, color));
476        }
477        fn fill_rounded_rect_brush(&mut self, o: Point, s: Size, radius: f64, brush: &Brush) {
478            self.brushes.push((o, s, radius, brush.clone()));
479        }
480    }
481
482    fn paint_colors(value: f64, theme: Option<&Theme>) -> Vec<Color> {
483        let mut w = widget(value);
484        let mut rec = TrackRecorder::default();
485        let mut ctx = match theme {
486            Some(t) => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(t),
487            None => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)),
488        };
489        w.paint(&mut ctx, &mut rec);
490        rec.rrects
491    }
492
493    #[test]
494    fn unthemed_paint_uses_fallback_constants() {
495        assert_eq!(paint_colors(0.5, None), vec![TRACK, FILL, THUMB_FILL]);
496    }
497
498    #[test]
499    fn themed_paint_resolves_the_scheme_s_slider_roles() {
500        let theme = Theme::neutral();
501        let scheme = theme.scheme();
502        assert_eq!(
503            paint_colors(0.5, Some(&theme)),
504            vec![
505                scheme.surface_container_highest,
506                scheme.primary,
507                scheme.primary
508            ],
509        );
510    }
511
512    #[test]
513    fn non_cupertino_and_unthemed_paint_have_no_reflective_treatment() {
514        // No draw_shadow/fill_rounded_rect_brush call leaks into the default
515        // (or unthemed) path — only a `Cupertino`-tagged theme takes the
516        // reflective branch.
517        let mut unthemed = widget(0.5);
518        let mut rec = TrackRecorder::default();
519        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT));
520        unthemed.paint(&mut ctx, &mut rec);
521        assert_eq!(rec.rrects.len(), 3, "track + fill + thumb, nothing else");
522        assert!(rec.shadows.is_empty());
523        assert!(rec.brushes.is_empty());
524
525        let theme = Theme::neutral();
526        assert_ne!(
527            theme.design_language,
528            DesignLanguage::Cupertino,
529            "the default path under test must not be the Cupertino one"
530        );
531        let mut themed = widget(0.5);
532        let mut rec = TrackRecorder::default();
533        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
534        themed.paint(&mut ctx, &mut rec);
535        assert_eq!(rec.rrects.len(), 3);
536        assert!(rec.shadows.is_empty());
537        assert!(rec.brushes.is_empty());
538    }
539
540    /// A `Cupertino`-tagged theme carrying a translucent `control` glass tier
541    /// — the shape a Cupertino design system installs, built inline here
542    /// because no design language ships in this crate. The shadow/hairline
543    /// values are arbitrary-but-distinctive: the branch under test reads them
544    /// straight off the theme, so what matters is that the assertions below
545    /// track THESE numbers rather than a hardcoded recipe.
546    fn cupertino_theme() -> Theme {
547        Theme::builder(Theme::neutral())
548            .design_language(DesignLanguage::Cupertino)
549            .map_glass(|mut g| {
550                g.control = GlassMaterial {
551                    blur_radius_intent: 15.0,
552                    fills_light: Vec::new(),
553                    fills_dark: Vec::new(),
554                    hairline_alpha: 0.3,
555                    shadow: frust_theme::ShadowSpec {
556                        y_offset: 2.0,
557                        blur_std_dev: 4.0,
558                        color_alpha: 0.12,
559                    },
560                };
561                g
562            })
563            .build()
564    }
565
566    #[test]
567    fn cupertino_themed_paint_uses_the_larger_reflective_knob() {
568        let theme = cupertino_theme();
569        assert_eq!(theme.design_language, DesignLanguage::Cupertino);
570        let mut w = widget(0.5);
571        let mut rec = TrackRecorder::default();
572        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
573        w.paint(&mut ctx, &mut rec);
574
575        // track + fill + knob base fill (white, unlike the Material path's
576        // tinted `THUMB_FILL`).
577        assert_eq!(rec.rrects.len(), 3);
578        assert_eq!(rec.rrects[2], CUPERTINO_THUMB_FILL);
579
580        assert_eq!(rec.shadows.len(), 1, "one drop shadow under the knob");
581        let expected = theme.glass.control.shadow;
582        assert_eq!(
583            rec.shadows[0].2,
584            CUPERTINO_THUMB / 2.0,
585            "circular, knob radius"
586        );
587        assert_eq!(rec.shadows[0].3, expected.blur_std_dev);
588
589        assert_eq!(rec.brushes.len(), 1, "one specular gradient highlight");
590        assert_eq!(
591            rec.brushes[0].2,
592            CUPERTINO_THUMB / 2.0,
593            "circular, knob radius"
594        );
595        match &rec.brushes[0].3 {
596            Brush::Gradient(g) => assert_eq!(g.stops.len(), 2, "peak + fade-to-transparent"),
597            _ => panic!("expected a gradient brush for the specular highlight"),
598        }
599    }
600
601    #[test]
602    fn rebuild_adopts_new_value_without_self_mutation() {
603        let mut counter = 0u64;
604        let prev = slider::<Val, _>(0.2, |_s, _v| {});
605        let mut w = View::<Val>::build(&prev, &mut BuildCtx::new(&mut counter));
606        assert_eq!(w.value, 0.2);
607        let next = slider::<Val, _>(0.8, |_s, _v| {});
608        View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
609        assert_eq!(w.value, 0.8);
610    }
611}