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