Skip to main content

gpui_kit_material/components/
slider.rs

1//! M3 Expressive sliders over GPUI Base's slider behavior (pointer, touch,
2//! range thumbs and accessibility actions). Material owns the presentation:
3//! the five track sizes, standard/centered/range tracks split around a 4dp
4//! handle, discrete stop indicators, an icon inside the track, the value
5//! indicator bubble shown while pressed, vertical orientation and keyboard
6//! stepping. References: <https://m3e.kyralabs.dev/components/slider/>, the
7//! Figma kit's Standard/Centered/Range slider sets, and
8//! <https://md3.io/components/slider/> for the classic slider.
9use super::InteractionPreview;
10use super::button::ButtonFocusRing;
11use crate::{
12    color::ColorRole,
13    icons::Icon,
14    motion::{self, MotionToken},
15    theme::color,
16    tokens::{
17        state,
18        typography::{TypeRole, rem},
19    },
20};
21use gpui::{prelude::*, *};
22use gpui_base::slider::{
23    Slider, SliderEvent, SliderIndicator, SliderState, SliderThumb, SliderTrack,
24};
25
26/// The M3 Expressive slider sizes (track thickness 16/24/40/56/96dp).
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
28pub enum SliderSize {
29    #[default]
30    XSmall,
31    Small,
32    Medium,
33    Large,
34    XLarge,
35}
36
37impl SliderSize {
38    pub const ALL: [Self; 5] = [
39        Self::XSmall,
40        Self::Small,
41        Self::Medium,
42        Self::Large,
43        Self::XLarge,
44    ];
45
46    /// Track thickness, handle length, outer track corner and icon size (dp).
47    const fn metrics(self) -> SizeMetrics {
48        let (track, handle, corner, icon) = match self {
49            Self::XSmall => (16., 44., 8., 0.),
50            Self::Small => (24., 44., 8., 0.),
51            Self::Medium => (40., 52., 12., 24.),
52            Self::Large => (56., 68., 16., 24.),
53            Self::XLarge => (96., 108., 28., 32.),
54        };
55        SizeMetrics {
56            track,
57            handle,
58            corner,
59            icon,
60        }
61    }
62
63    /// The kit's name for the size; galleries may use it as a label.
64    pub const fn name(self) -> &'static str {
65        match self {
66            Self::XSmall => "XSmall",
67            Self::Small => "Small",
68            Self::Medium => "Medium",
69            Self::Large => "Large",
70            Self::XLarge => "XLarge",
71        }
72    }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq)]
76struct SizeMetrics {
77    track: f32,
78    handle: f32,
79    corner: f32,
80    icon: f32,
81}
82
83/// Slider geometry. Classic matches the Google Fonts axis controls; Expressive
84/// follows the M3 Expressive thick track and vertical handle presentation.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
86pub enum SliderAppearance {
87    Classic,
88    #[default]
89    Expressive,
90}
91
92/// The slider's accent (md3.io's Color option). Primary is the M3 default:
93/// a primary active track and handle over a secondary-container inactive
94/// track. The others use their own family throughout: the role for the
95/// active track and handle, its container for the inactive track, and the
96/// matching on-roles for stops and icons.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
98pub enum SliderColor {
99    #[default]
100    Primary,
101    Secondary,
102    Tertiary,
103    Error,
104}
105
106impl SliderColor {
107    pub const ALL: [Self; 4] = [Self::Primary, Self::Secondary, Self::Tertiary, Self::Error];
108    /// (active, on-active, inactive, on-inactive) roles.
109    const fn roles(self) -> (ColorRole, ColorRole, ColorRole, ColorRole) {
110        use ColorRole as C;
111        match self {
112            Self::Primary => (
113                C::PRIMARY,
114                C::ON_PRIMARY,
115                C::SECONDARY_CONTAINER,
116                C::ON_SECONDARY_CONTAINER,
117            ),
118            Self::Secondary => (
119                C::SECONDARY,
120                C::ON_SECONDARY,
121                C::SECONDARY_CONTAINER,
122                C::ON_SECONDARY_CONTAINER,
123            ),
124            Self::Tertiary => (
125                C::TERTIARY,
126                C::ON_TERTIARY,
127                C::TERTIARY_CONTAINER,
128                C::ON_TERTIARY_CONTAINER,
129            ),
130            Self::Error => (
131                C::ERROR,
132                C::ON_ERROR,
133                C::ERROR_CONTAINER,
134                C::ON_ERROR_CONTAINER,
135            ),
136        }
137    }
138}
139
140/// M3 Expressive slider details, in dp: the gap between the handle and the
141/// track on either side, the corners where the track meets the gap, the
142/// handle's width (narrowing while pressed) and the stop indicators.
143const HANDLE_GAP: f32 = 6.;
144const TRACK_INNER_CORNER: f32 = 2.;
145const HANDLE_WIDTH: f32 = 4.;
146const HANDLE_PRESSED_WIDTH: f32 = 2.;
147const STOP_INDICATOR: f32 = 4.;
148/// A stop's center never sits closer than this to a track end.
149const STOP_INSET: f32 = 6.;
150/// The value indicator: a 48x44dp minimum inverse-surface pill, 4dp from
151/// the handle.
152const BUBBLE_HEIGHT: f32 = 44.;
153const BUBBLE_MIN_WIDTH: f32 = 48.;
154const BUBBLE_PADDING: f32 = 16.;
155const BUBBLE_GAP: f32 = 4.;
156/// Below this fraction the icon moves from the active to the inactive track.
157const ICON_MIN_FRACTION: f32 = 0.25;
158
159/// A value-indicator formatter.
160type ValueFormat = std::rc::Rc<dyn Fn(f32) -> SharedString>;
161
162#[derive(gpui::IntoElement)]
163pub struct MaterialSlider {
164    id: ElementId,
165    state: Entity<SliderState>,
166    label: SharedString,
167    appearance: SliderAppearance,
168    size: SliderSize,
169    axis: Axis,
170    centered: bool,
171    stops: bool,
172    color: SliderColor,
173    icon: Option<Icon>,
174    value_indicator: bool,
175    format: Option<ValueFormat>,
176    disabled: bool,
177    preview: Option<InteractionPreview>,
178}
179
180impl MaterialSlider {
181    pub fn new(state: &Entity<SliderState>, label: impl Into<SharedString>) -> Self {
182        Self {
183            id: ElementId::Integer(state.entity_id().as_u64()),
184            state: state.clone(),
185            label: label.into(),
186            appearance: SliderAppearance::default(),
187            size: SliderSize::default(),
188            axis: Axis::Horizontal,
189            centered: false,
190            stops: false,
191            color: SliderColor::Primary,
192            icon: None,
193            value_indicator: true,
194            format: None,
195            disabled: false,
196            preview: None,
197        }
198    }
199
200    /// Distinguish sliders that share one state (e.g. specimens).
201    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
202        self.id = id.into();
203        self
204    }
205    pub fn appearance(mut self, appearance: SliderAppearance) -> Self {
206        self.appearance = appearance;
207        self
208    }
209    pub fn size(mut self, size: SliderSize) -> Self {
210        self.size = size;
211        self
212    }
213    pub fn vertical(mut self) -> Self {
214        self.axis = Axis::Vertical;
215        self
216    }
217    pub fn axis(mut self, axis: Axis) -> Self {
218        self.axis = axis;
219        self
220    }
221    /// The active track grows from the middle of the range toward the value.
222    pub fn centered(mut self, centered: bool) -> Self {
223        self.centered = centered;
224        self
225    }
226    /// Show a stop indicator at every step (a discrete slider).
227    pub fn stops(mut self, stops: bool) -> Self {
228        self.stops = stops;
229        self
230    }
231    /// The accent color family (default primary). See [`SliderColor`].
232    pub fn color(mut self, color: SliderColor) -> Self {
233        self.color = color;
234        self
235    }
236    /// An icon inside the track's leading end (medium and larger sizes).
237    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
238        self.icon = Some(icon.into());
239        self
240    }
241    /// Whether pressing shows the value indicator (default: shown).
242    pub fn value_indicator(mut self, show: bool) -> Self {
243        self.value_indicator = show;
244        self
245    }
246    /// The value indicator's text; by default the value rounded.
247    pub fn format(mut self, format: impl Fn(f32) -> SharedString + 'static) -> Self {
248        self.format = Some(std::rc::Rc::new(format));
249        self
250    }
251    pub fn disabled(mut self, disabled: bool) -> Self {
252        self.disabled = disabled;
253        self
254    }
255    /// Render a fixed interaction state as a specimen.
256    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
257        self.preview = Some(state);
258        self
259    }
260}
261
262fn slider_geometry(appearance: SliderAppearance) -> (f32, f32, f32) {
263    match appearance {
264        SliderAppearance::Classic => (4., 20., 20.),
265        SliderAppearance::Expressive => (16., 4., 44.),
266    }
267}
268
269/// The colors of a slider in one state. Disabled colors follow the kit's
270/// tokens through [`state`], composited rather than pre-mixed (the policy
271/// documented on the switch): disabled content is its role at
272/// [`state::DISABLED_CONTENT`] and the disabled inactive track at
273/// [`state::DISABLED_CONTAINER`].
274#[derive(Clone, Copy, Debug, PartialEq)]
275struct SliderPaint {
276    active: Rgba,
277    inactive: Rgba,
278    handle: Rgba,
279    /// Stops and the icon over the active track.
280    on_active: Rgba,
281    /// Stops and the icon over the inactive track.
282    on_inactive: Rgba,
283}
284
285fn slider_paint(disabled: bool, accent: SliderColor) -> SliderPaint {
286    if disabled {
287        let on_surface = color(ColorRole::ON_SURFACE);
288        SliderPaint {
289            active: state::disabled(on_surface),
290            inactive: on_surface.opacity(state::DISABLED_CONTAINER),
291            handle: state::disabled(on_surface),
292            on_active: color(ColorRole::INVERSE_ON_SURFACE),
293            on_inactive: state::disabled(on_surface),
294        }
295    } else {
296        let (active, on_active, inactive, on_inactive) = accent.roles();
297        SliderPaint {
298            active: color(active),
299            inactive: color(inactive),
300            handle: color(active),
301            on_active: color(on_active),
302            on_inactive: color(on_inactive),
303        }
304    }
305}
306
307/// One track piece along the main axis (px from the start: left, or the
308/// bottom of a vertical slider).
309#[derive(Clone, Copy, Debug, PartialEq)]
310struct Segment {
311    start: f32,
312    end: f32,
313    active: bool,
314    /// Whether each end is a track end (outer corner) rather than a gap.
315    outer_start: bool,
316    outer_end: bool,
317}
318
319/// A handle's center along a track `length` long, keeping the handle
320/// inside the track.
321fn handle_center(length: f32, handle: f32, fraction: f32) -> f32 {
322    handle / 2. + fraction.clamp(0., 1.) * (length - handle).max(0.)
323}
324
325/// Split the track around the handles. `handles` are fractions (one, or a
326/// range's two); `centered` grows the active track from the middle.
327fn track_segments(length: f32, handle: f32, handles: &[f32], centered: bool) -> Vec<Segment> {
328    let clear = handle / 2. + HANDLE_GAP;
329    let centers: Vec<f32> = handles
330        .iter()
331        .map(|&f| handle_center(length, handle, f))
332        .collect();
333    let mut segments = Vec::new();
334    let mut push = |start: f32, end: f32, active, outer_start, outer_end| {
335        if end - start > 0.5 {
336            segments.push(Segment {
337                start,
338                end,
339                active,
340                outer_start,
341                outer_end,
342            });
343        }
344    };
345    match (centers.as_slice(), centered) {
346        ([low, high], _) => {
347            push(0., low - clear, false, true, false);
348            push(low + clear, high - clear, true, false, false);
349            push(high + clear, length, false, false, true);
350        }
351        ([at], true) => {
352            let middle = length / 2.;
353            if *at >= middle {
354                push(0., middle, false, true, false);
355                push(middle, at - clear, true, false, false);
356                push(at + clear, length, false, false, true);
357            } else {
358                push(0., at - clear, false, true, false);
359                push(at + clear, middle, true, false, false);
360                push(middle, length, false, false, true);
361            }
362        }
363        ([at], false) => {
364            push(0., at - clear, true, true, false);
365            push(at + clear, length, false, false, true);
366        }
367        _ => push(0., length, false, true, true),
368    }
369    segments
370}
371
372/// Stop indicator centers along the track and whether each lies on the
373/// active track; stops under a handle (or its gap) are hidden. Without
374/// `steps`, only the inactive ends carry a stop.
375fn stop_positions(
376    length: f32,
377    handle: f32,
378    segments: &[Segment],
379    steps: Option<usize>,
380    handles: &[f32],
381) -> Vec<(f32, bool)> {
382    let clear = handle / 2. + HANDLE_GAP;
383    let inset = |x: f32| x.clamp(STOP_INSET, (length - STOP_INSET).max(STOP_INSET));
384    let candidates: Vec<f32> = match steps {
385        Some(count) if count >= 1 => (0..=count)
386            .map(|i| inset(handle_center(length, handle, i as f32 / count as f32)))
387            .collect(),
388        _ => segments
389            .iter()
390            .filter(|s| !s.active)
391            .flat_map(|s| {
392                [
393                    s.outer_start.then_some(STOP_INSET),
394                    s.outer_end.then_some(length - STOP_INSET),
395                ]
396            })
397            .flatten()
398            .collect(),
399    };
400    candidates
401        .into_iter()
402        .filter(|&x| {
403            handles
404                .iter()
405                .all(|&f| (x - handle_center(length, handle, f)).abs() > clear - 1.)
406        })
407        .map(|x| {
408            let active = segments
409                .iter()
410                .any(|s| s.active && x >= s.start && x <= s.end);
411            (x, active)
412        })
413        .collect()
414}
415
416/// Everything the track canvas paints, in design px.
417#[derive(Clone, Debug)]
418struct TrackPaint {
419    axis: Axis,
420    metrics: SizeMetrics,
421    handle: f32,
422    handles: Vec<f32>,
423    centered: bool,
424    steps: Option<usize>,
425    paint: SliderPaint,
426    /// Pixels per design px.
427    scale: f32,
428}
429
430fn paint_track(bounds: Bounds<Pixels>, track: &TrackPaint, window: &mut Window) {
431    let s = track.scale;
432    let horizontal = track.axis == Axis::Horizontal;
433    let length = f32::from(if horizontal {
434        bounds.size.width
435    } else {
436        bounds.size.height
437    }) / s;
438    let thickness = track.metrics.track;
439    let segments = track_segments(length, track.handle, &track.handles, track.centered);
440    // A main-axis span and a cross-axis centered band, in window px.
441    let rect = |start: f32, end: f32, cross: f32| {
442        let (a, b) = (start * s, end * s);
443        if horizontal {
444            Bounds::new(
445                point(
446                    bounds.origin.x + px(a),
447                    bounds.center().y - px(cross * s / 2.),
448                ),
449                size(px(b - a), px(cross * s)),
450            )
451        } else {
452            Bounds::new(
453                point(
454                    bounds.center().x - px(cross * s / 2.),
455                    bounds.bottom() - px(b),
456                ),
457                size(px(cross * s), px(b - a)),
458            )
459        }
460    };
461    for segment in &segments {
462        let half = (segment.end - segment.start).min(thickness) / 2.;
463        let corner = |outer: bool| {
464            px(if outer {
465                track.metrics.corner
466            } else {
467                TRACK_INNER_CORNER
468            }
469            .min(half)
470                * s)
471        };
472        let (start, end) = (corner(segment.outer_start), corner(segment.outer_end));
473        let radii = if horizontal {
474            Corners {
475                top_left: start,
476                bottom_left: start,
477                top_right: end,
478                bottom_right: end,
479            }
480        } else {
481            Corners {
482                bottom_left: start,
483                bottom_right: start,
484                top_left: end,
485                top_right: end,
486            }
487        };
488        let ink = if segment.active {
489            track.paint.active
490        } else {
491            track.paint.inactive
492        };
493        window
494            .paint_quad(fill(rect(segment.start, segment.end, thickness), ink).corner_radii(radii));
495    }
496    for (at, active) in stop_positions(length, track.handle, &segments, track.steps, &track.handles)
497    {
498        let ink = if active {
499            track.paint.on_active
500        } else {
501            track.paint.on_inactive
502        };
503        let dot = rect(
504            at - STOP_INDICATOR / 2.,
505            at + STOP_INDICATOR / 2.,
506            STOP_INDICATOR,
507        );
508        window.paint_quad(fill(dot, ink).corner_radii(px(STOP_INDICATOR * s / 2.)));
509    }
510}
511
512/// The value as a rounded number, the default indicator text.
513fn default_format(value: f32) -> SharedString {
514    let rounded = value.round();
515    if (value - rounded).abs() < 0.005 {
516        format!("{}", rounded as i64).into()
517    } else {
518        format!("{value:.1}").into()
519    }
520}
521
522/// The classic (M3 baseline) slider: a 4dp track and a 20dp round handle
523/// with a 40dp state layer, in a 48dp tall target. It shares the
524/// Expressive slider's focus, keyboard, press, preview, axis and range
525/// handling; only the geometry differs.
526const CLASSIC_TARGET: f32 = 48.;
527const CLASSIC_STATE_LAYER: f32 = 40.;
528
529/// Keyboard stepping: arrows by one step, Page keys by a tenth of the range
530/// rounded to whole steps, Home/End to the ends. `bounds` limits the moving
531/// thumb (a range thumb stops at the other one); `range` is the slider's
532/// min and max, the origin of its step grid. `None` for other keys or an
533/// invalid range (min above max, or NaN), which would otherwise panic.
534fn stepped_value(
535    key: &str,
536    value: f32,
537    bounds: (f32, f32),
538    range: (f32, f32),
539    step: f32,
540) -> Option<f32> {
541    let ((lo, hi), (min, max)) = (bounds, range);
542    if !(min <= max && lo <= hi) || value.is_nan() {
543        return None;
544    }
545    let discrete = step > 0. && step.is_finite();
546    let step = if discrete { step } else { (max - min) / 100. };
547    let page = if step > 0. {
548        ((max - min) / 10. / step).round().max(1.) * step
549    } else {
550        0.
551    };
552    let next = match key {
553        "right" | "up" => value + step,
554        "left" | "down" => value - step,
555        "pageup" => value + page,
556        "pagedown" => value - page,
557        "home" => lo,
558        "end" => hi,
559        _ => return None,
560    };
561    let next = if discrete {
562        min + ((next - min) / step).round() * step
563    } else {
564        next
565    };
566    Some(next.min(hi).max(lo))
567}
568
569impl RenderOnce for MaterialSlider {
570    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
571        let id = self.id.clone();
572        let classic = self.appearance == SliderAppearance::Classic;
573        // A single slider focuses its Role::Slider node; a range focuses each
574        // thumb, so Tab moves between them and each takes the arrow keys.
575        let focus = window
576            .use_keyed_state((id.clone(), "focus"), cx, |_, cx| {
577                cx.focus_handle().tab_stop(true)
578            })
579            .read(cx)
580            .clone();
581        let start_focus = window
582            .use_keyed_state((id.clone(), "focus-start"), cx, |_, cx| {
583                cx.focus_handle().tab_stop(true)
584            })
585            .read(cx)
586            .clone();
587        let press = window.use_keyed_state((id.clone(), "pressed"), cx, |_, _| false);
588        let preview = self.preview;
589        let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
590        let live = !disabled && preview.is_none();
591        let forced = |s| !disabled && preview == Some(s);
592        let hovered = forced(InteractionPreview::Hovered);
593        let pressed = forced(InteractionPreview::Pressed) || (live && *press.read(cx));
594
595        let state = self.state.read(cx);
596        let value = state.value();
597        let is_range = value.is_range();
598        let (min, max, step) = (state.min_value(), state.max_value(), state.step_value());
599        let percentage = state.percentage();
600        let handles: Vec<f32> = if is_range {
601            vec![percentage.start, percentage.end]
602        } else {
603            vec![percentage.end]
604        };
605        let values: Vec<f32> = if is_range {
606            vec![value.start(), value.end()]
607        } else {
608            vec![value.end()]
609        };
610        // Which thumb holds keyboard focus.
611        let keyboard = live && window.last_input_was_keyboard();
612        let focused_thumb = if is_range {
613            if start_focus.is_focused(window) {
614                Some(0)
615            } else if focus.is_focused(window) {
616                Some(1)
617            } else {
618                None
619            }
620        } else {
621            focus.is_focused(window).then_some(0)
622        };
623        let thumb_focused = move |i: usize| {
624            forced(InteractionPreview::Focused) || (keyboard && focused_thumb == Some(i))
625        };
626        let steps = (self.stops && step > 0. && max > min)
627            .then(|| ((max - min) / step).round() as usize)
628            .filter(|n| (1..=200).contains(n));
629
630        let metrics = self.size.metrics();
631        let target_width = if pressed {
632            HANDLE_PRESSED_WIDTH
633        } else {
634            HANDLE_WIDTH
635        };
636        let handle = if classic {
637            slider_geometry(SliderAppearance::Classic).1
638        } else if preview.is_some() {
639            target_width
640        } else {
641            motion::token_value(
642                (id.clone(), "handle-width"),
643                target_width,
644                MotionToken::FastSpatial,
645                window,
646                cx,
647            )
648            .clamp(HANDLE_PRESSED_WIDTH, HANDLE_WIDTH)
649        };
650        let bubble = if !self.value_indicator || classic {
651            0.
652        } else if preview.is_some() {
653            if pressed { 1. } else { 0. }
654        } else {
655            motion::token_value(
656                (id.clone(), "value-indicator"),
657                if pressed { 1. } else { 0. },
658                MotionToken::FastEffects,
659                window,
660                cx,
661            )
662            .clamp(0., 1.)
663        };
664        let paint = slider_paint(disabled, self.color);
665        // A handle's state layer: on-primary over the Expressive handle,
666        // primary around the classic one.
667        let layer = |i: usize| {
668            if thumb_focused(i) {
669                state::FOCUS
670            } else if pressed {
671                state::PRESS
672            } else if hovered {
673                state::HOVER
674            } else {
675                0.
676            }
677        };
678        let live_hover = state::layer(paint.handle, paint.on_active, state::HOVER);
679        let horizontal = self.axis == Axis::Horizontal;
680        let cross = if classic {
681            CLASSIC_TARGET
682        } else {
683            metrics.handle
684        };
685        let format = self
686            .format
687            .unwrap_or_else(|| std::rc::Rc::new(default_format));
688        let ring_radius = rem(handle / 2.);
689        let label = self.label;
690        let orientation = if horizontal {
691            Orientation::Horizontal
692        } else {
693            Orientation::Vertical
694        };
695
696        let thumbs = handles
697            .iter()
698            .zip(values.iter())
699            .enumerate()
700            .map(|(i, (&p, &v))| {
701                let start = is_range && i == 0;
702                let focused = thumb_focused(i);
703                let thumb = SliderThumb::new(&self.state)
704                    .axis(self.axis)
705                    .start(start)
706                    .disabled(disabled)
707                    .absolute()
708                    .when(is_range, |thumb| {
709                        // Each range thumb is a focusable slider of its own.
710                        let handle = if start { &start_focus } else { &focus };
711                        thumb
712                            .role(Role::Slider)
713                            .aria_label(label.clone())
714                            .aria_numeric_value(v as f64)
715                            .aria_min_numeric_value(min as f64)
716                            .aria_max_numeric_value(max as f64)
717                            .aria_orientation(orientation)
718                            .when(live, |thumb| thumb.track_focus(handle).tab_stop(true))
719                    });
720                if classic {
721                    let size = handle;
722                    let thumb = thumb.rounded_full().bg(paint.handle);
723                    let thumb = if horizontal {
724                        thumb
725                            .left(relative(p))
726                            .ml(rem(-size / 2.))
727                            .top(rem((CLASSIC_TARGET - size) / 2.))
728                    } else {
729                        thumb
730                            .bottom(relative(p))
731                            .mb(rem(-size / 2.))
732                            .left(rem((CLASSIC_TARGET - size) / 2.))
733                    }
734                    .w(rem(size))
735                    .h(rem(size));
736                    let opacity = layer(i);
737                    let halo = (CLASSIC_STATE_LAYER - size) / 2.;
738                    let thumb = thumb.when(opacity > 0. && !disabled, |thumb| {
739                        thumb.child(
740                            div()
741                                .absolute()
742                                .top(rem(-halo))
743                                .left(rem(-halo))
744                                .size(rem(CLASSIC_STATE_LAYER))
745                                .rounded_full()
746                                .bg(paint.handle.opacity(opacity)),
747                        )
748                    });
749                    return ButtonFocusRing::resolved(thumb, focused, ring_radius)
750                        .into_any_element();
751                }
752                let handle_ink = if disabled {
753                    paint.handle
754                } else {
755                    state::layer(paint.handle, paint.on_active, layer(i))
756                };
757                let thumb = thumb
758                    .rounded(rem(2.))
759                    .bg(handle_ink)
760                    .when(live && !pressed && !focused, |t| {
761                        t.hover(move |s| s.bg(live_hover))
762                    });
763                let thumb = if horizontal {
764                    thumb
765                        .left(relative(p))
766                        .ml(rem(-handle / 2.))
767                        .top_0()
768                        .h_full()
769                        .w(rem(handle))
770                } else {
771                    thumb
772                        .bottom(relative(p))
773                        .mb(rem(-handle / 2.))
774                        .left_0()
775                        .w_full()
776                        .h(rem(handle))
777                };
778                let text = format(v);
779                // A specimen labels only the end thumb, as the kit does.
780                let labelled = preview.is_none() || i + 1 == handles.len();
781                let thumb = thumb.when(bubble > 0.01 && labelled, |thumb| {
782                    let pill = div()
783                        .absolute()
784                        .flex()
785                        .items_center()
786                        .justify_center()
787                        .h(rem(BUBBLE_HEIGHT))
788                        .min_w(rem(BUBBLE_MIN_WIDTH))
789                        .px(rem(BUBBLE_PADDING))
790                        .rounded_full()
791                        .bg(color(ColorRole::INVERSE_SURFACE))
792                        .text_color(color(ColorRole::INVERSE_ON_SURFACE))
793                        .map(|pill| {
794                            let scale = TypeRole::LabelLarge.scale();
795                            pill.text_size(scale.font_size)
796                                .line_height(scale.line_height)
797                                .font_weight(FontWeight(scale.font_weight as f32))
798                        })
799                        .whitespace_nowrap()
800                        .opacity(bubble)
801                        .child(text);
802                    // Centered on the handle, outside its far edge: a fixed-size
803                    // anchor box keeps the pill centered without measuring it.
804                    let anchor = div().absolute().flex().items_center().justify_center();
805                    let anchor = if horizontal {
806                        anchor
807                            .bottom(relative(1.))
808                            .mb(rem(BUBBLE_GAP - (1. - bubble) * 8.))
809                            .left(rem(handle / 2. - 60.))
810                            .w(rem(120.))
811                            .h(rem(BUBBLE_HEIGHT))
812                    } else {
813                        anchor
814                            .right(relative(1.))
815                            .mr(rem(BUBBLE_GAP + metrics.handle / 2. - (1. - bubble) * 8.))
816                            .top(rem(handle / 2. - BUBBLE_HEIGHT / 2.))
817                            .w(rem(120.))
818                            .h(rem(BUBBLE_HEIGHT))
819                            .justify_end()
820                    };
821                    thumb.child(anchor.child(pill.relative()))
822                });
823                ButtonFocusRing::resolved(thumb, focused, ring_radius).into_any_element()
824            });
825
826        let track_view = if classic {
827            classic_track(&self.state, self.axis, disabled, &handles, &paint, thumbs)
828        } else {
829            let scale = f32::from(rem(1.).to_pixels(window.rem_size()));
830            let track = TrackPaint {
831                axis: self.axis,
832                metrics,
833                handle,
834                handles: handles.clone(),
835                centered: self.centered,
836                steps,
837                paint,
838                scale,
839            };
840            // The icon sits inside the active track's leading end, or past the
841            // handle on the inactive track when the active part is too short.
842            let icon = self
843                .icon
844                .filter(|_| metrics.icon > 0. && !self.centered && !is_range)
845                .map(|icon| {
846                    let inset = ((metrics.track - metrics.icon) / 2.).min(16.);
847                    (icon, inset, handles[0])
848                });
849            let indicator = SliderIndicator::new(&self.state)
850                .absolute()
851                .children(thumbs);
852            let indicator = if horizontal {
853                indicator
854                    .left(rem(handle / 2.))
855                    .right(rem(handle / 2.))
856                    .top_0()
857                    .bottom_0()
858            } else {
859                indicator
860                    .top(rem(handle / 2.))
861                    .bottom(rem(handle / 2.))
862                    .left_0()
863                    .right_0()
864            };
865            SliderTrack::new(&self.state)
866                .axis(self.axis)
867                .disabled(disabled)
868                .relative()
869                .size_full()
870                .child(
871                    canvas(
872                        |_, _, _| {},
873                        move |bounds, _, window, _| paint_track(bounds, &track, window),
874                    )
875                    .absolute()
876                    .size_full(),
877                )
878                .when_some(icon, |view, (icon, inset, p)| {
879                    let size = metrics.icon;
880                    let cross = (metrics.handle - size) / 2.;
881                    // Too short an active track moves the icon past the handle.
882                    let on_active = p >= ICON_MIN_FRACTION;
883                    let ink = if on_active {
884                        paint.on_active
885                    } else {
886                        paint.on_inactive
887                    };
888                    let slot = div()
889                        .absolute()
890                        .size(rem(size))
891                        .child(icon.size(rem(size)).color(ink));
892                    view.child(match (horizontal, on_active) {
893                        (true, true) => slot.top(rem(cross)).left(rem(inset)),
894                        (true, false) => slot
895                            .top(rem(cross))
896                            .left(relative(p))
897                            .ml(rem(handle + HANDLE_GAP + inset)),
898                        (false, true) => slot.left(rem(cross)).bottom(rem(inset)),
899                        (false, false) => slot
900                            .left(rem(cross))
901                            .bottom(relative(p))
902                            .mb(rem(handle + HANDLE_GAP + inset)),
903                    })
904                })
905                .child(indicator)
906                .into_any_element()
907        };
908
909        let slider = Slider::new(&self.state)
910            .axis(self.axis)
911            .accessibility_label(label)
912            .disabled(disabled)
913            .when(live && !is_range, |slider| slider.track_focus(&focus))
914            .size_full()
915            .child(track_view);
916        let state_entity = self.state.clone();
917        let (release, release_out) = (press.clone(), press.clone());
918        div()
919            .id(id)
920            .flex_shrink_0()
921            .map(|root| {
922                if horizontal {
923                    root.w_full().h(rem(cross))
924                } else {
925                    root.h_full().w(rem(cross))
926                }
927            })
928            .when(live, |root| {
929                root.capture_any_mouse_down(move |event, window, cx| {
930                    // Only the primary button presses (and releases) the slider.
931                    if event.button != MouseButton::Left {
932                        return;
933                    }
934                    press.update(cx, |pressed, _| *pressed = true);
935                    window.refresh();
936                })
937                .on_mouse_up(MouseButton::Left, move |_, window, cx| {
938                    release.update(cx, |pressed, _| *pressed = false);
939                    window.refresh();
940                })
941                .on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
942                    release_out.update(cx, |pressed, _| *pressed = false);
943                    window.refresh();
944                })
945                .on_key_down(move |event, window, cx| {
946                    let key = event.keystroke.key.as_str();
947                    let moves_start = is_range && start_focus.is_focused(window);
948                    state_entity.update(cx, |state, cx| {
949                        let value = state.value();
950                        let range = (state.min_value(), state.max_value());
951                        let (current, bounds) = if moves_start {
952                            (value.start(), (range.0, value.end()))
953                        } else if value.is_range() {
954                            (value.end(), (value.start(), range.1))
955                        } else {
956                            (value.end(), range)
957                        };
958                        let Some(next) =
959                            stepped_value(key, current, bounds, range, state.step_value())
960                        else {
961                            return;
962                        };
963                        cx.stop_propagation();
964                        // At an end the key changes nothing and reports nothing.
965                        if next == current {
966                            return;
967                        }
968                        if moves_start {
969                            state.set_value((next, value.end()), window, cx);
970                        } else if value.is_range() {
971                            state.set_value((value.start(), next), window, cx);
972                        } else {
973                            state.set_value(next, window, cx);
974                        }
975                        cx.emit(SliderEvent::Change(state.value()));
976                    });
977                })
978            })
979            .child(slider)
980            .into_any_element()
981    }
982}
983
984/// The classic track: a 4dp inactive bar with the active part from the
985/// start (or between a range's thumbs), along either axis.
986fn classic_track(
987    state: &Entity<SliderState>,
988    axis: Axis,
989    disabled: bool,
990    handles: &[f32],
991    paint: &SliderPaint,
992    thumbs: impl IntoIterator<Item = AnyElement>,
993) -> AnyElement {
994    let track = slider_geometry(SliderAppearance::Classic).0;
995    let inset = rem((CLASSIC_TARGET - track) / 2.);
996    let (from, to) = match handles {
997        [start, end] => (*start, *end),
998        [end] => (0., *end),
999        _ => (0., 0.),
1000    };
1001    let horizontal = axis == Axis::Horizontal;
1002    let active = div().absolute().rounded_full().bg(paint.active);
1003    let active = if horizontal {
1004        active
1005            .top_0()
1006            .h_full()
1007            .left(relative(from))
1008            .w(relative((to - from).max(0.)))
1009    } else {
1010        active
1011            .left_0()
1012            .w_full()
1013            .bottom(relative(from))
1014            .h(relative((to - from).max(0.)))
1015    };
1016    // The value-mapping bar spans the whole track; thumbs overhang its ends.
1017    let bar = SliderIndicator::new(state)
1018        .absolute()
1019        .rounded_full()
1020        .bg(paint.inactive)
1021        .child(active);
1022    let bar = if horizontal {
1023        bar.left_0().top(inset).w_full().h(rem(track))
1024    } else {
1025        bar.bottom_0().left(inset).h_full().w(rem(track))
1026    };
1027    // Thumbs are placed in the 48dp target, relative to the track view.
1028    let thumbs = div().absolute().inset_0().children(thumbs);
1029    SliderTrack::new(state)
1030        .axis(axis)
1031        .disabled(disabled)
1032        .relative()
1033        .size_full()
1034        .child(bar)
1035        .child(thumbs)
1036        .into_any_element()
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use core::prelude::v1::test;
1043    use std::{cell::Cell, rc::Rc};
1044
1045    #[test]
1046    fn sizes_follow_the_m3_expressive_tokens() {
1047        let tracks = SliderSize::ALL.map(|s| s.metrics().track);
1048        assert_eq!(tracks, [16., 24., 40., 56., 96.]);
1049        let handles = SliderSize::ALL.map(|s| s.metrics().handle);
1050        assert_eq!(handles, [44., 44., 52., 68., 108.]);
1051        let corners = SliderSize::ALL.map(|s| s.metrics().corner);
1052        assert_eq!(corners, [8., 8., 12., 16., 28.]);
1053        let icons = SliderSize::ALL.map(|s| s.metrics().icon);
1054        assert_eq!(icons, [0., 0., 24., 24., 32.]);
1055        assert_eq!(slider_geometry(SliderAppearance::Classic), (4., 20., 20.));
1056    }
1057
1058    #[test]
1059    fn the_track_splits_around_the_handle_with_6dp_gaps() {
1060        // A 4dp handle at 50% of 204dp is centered at 102dp.
1061        let s = track_segments(204., 4., &[0.5], false);
1062        assert_eq!(s.len(), 2);
1063        assert_eq!(
1064            (s[0].start, s[0].end, s[0].active),
1065            (0., 102. - 2. - 6., true)
1066        );
1067        assert_eq!(
1068            (s[1].start, s[1].end, s[1].active),
1069            (102. + 2. + 6., 204., false)
1070        );
1071        assert!(s[0].outer_start && !s[0].outer_end && s[1].outer_end);
1072        // At 0 the active track disappears; at 1 the inactive one does.
1073        assert!(
1074            track_segments(204., 4., &[0.], false)
1075                .iter()
1076                .all(|s| !s.active)
1077        );
1078        assert!(
1079            track_segments(204., 4., &[1.], false)
1080                .iter()
1081                .all(|s| s.active)
1082        );
1083    }
1084
1085    #[test]
1086    fn centered_and_range_tracks() {
1087        let right = track_segments(200., 4., &[0.75], true);
1088        let active: Vec<_> = right.iter().filter(|s| s.active).collect();
1089        assert_eq!(active.len(), 1);
1090        assert_eq!(active[0].start, 100.);
1091        let left = track_segments(200., 4., &[0.25], true);
1092        assert_eq!(left.iter().find(|s| s.active).unwrap().end, 100.);
1093        let range = track_segments(204., 4., &[0.25, 0.75], false);
1094        assert_eq!(
1095            range.iter().map(|s| s.active).collect::<Vec<_>>(),
1096            [false, true, false]
1097        );
1098        assert!(range[0].outer_start && range[2].outer_end);
1099    }
1100
1101    #[test]
1102    fn stops_mark_steps_and_hide_under_the_handle() {
1103        let segments = track_segments(204., 4., &[0.5], false);
1104        // Ten steps: eleven stops, the middle one hidden by the handle.
1105        let stops = stop_positions(204., 4., &segments, Some(10), &[0.5]);
1106        assert_eq!(stops.len(), 10);
1107        assert_eq!(stops.iter().filter(|(_, active)| *active).count(), 5);
1108        assert_eq!(stops[0].0, STOP_INSET, "end stops sit 6dp in");
1109        // Continuous: one stop at the inactive end only.
1110        let end = stop_positions(204., 4., &segments, None, &[0.5]);
1111        assert_eq!(end, vec![(204. - STOP_INSET, false)]);
1112    }
1113
1114    #[test]
1115    fn slider_uses_kit_roles_and_unified_disabled_tokens() {
1116        let enabled = slider_paint(false, SliderColor::Primary);
1117        assert_eq!(enabled.active, color(ColorRole::PRIMARY));
1118        assert_eq!(enabled.inactive, color(ColorRole::SECONDARY_CONTAINER));
1119        assert_eq!(enabled.on_active, color(ColorRole::ON_PRIMARY));
1120        assert_eq!(
1121            enabled.on_inactive,
1122            color(ColorRole::ON_SECONDARY_CONTAINER)
1123        );
1124        let disabled = slider_paint(true, SliderColor::Tertiary);
1125        let on_surface = color(ColorRole::ON_SURFACE);
1126        assert_eq!(disabled.active, state::disabled(on_surface));
1127        assert_eq!(disabled.handle, state::disabled(on_surface));
1128        assert_eq!(
1129            disabled.inactive,
1130            on_surface.opacity(state::DISABLED_CONTAINER)
1131        );
1132    }
1133
1134    #[test]
1135    fn slider_colors_use_their_own_role_family() {
1136        let tertiary = slider_paint(false, SliderColor::Tertiary);
1137        assert_eq!(tertiary.active, color(ColorRole::TERTIARY));
1138        assert_eq!(tertiary.handle, color(ColorRole::TERTIARY));
1139        assert_eq!(tertiary.inactive, color(ColorRole::TERTIARY_CONTAINER));
1140        assert_eq!(tertiary.on_active, color(ColorRole::ON_TERTIARY));
1141        let error = slider_paint(false, SliderColor::Error);
1142        assert_eq!(error.active, color(ColorRole::ERROR));
1143        assert_eq!(error.inactive, color(ColorRole::ERROR_CONTAINER));
1144        assert_eq!(error.on_inactive, color(ColorRole::ON_ERROR_CONTAINER));
1145        let secondary = slider_paint(false, SliderColor::Secondary);
1146        assert_eq!(secondary.active, color(ColorRole::SECONDARY));
1147        assert_eq!(
1148            slider_paint(true, SliderColor::Error),
1149            slider_paint(true, SliderColor::Primary),
1150            "disabled ignores the accent"
1151        );
1152    }
1153
1154    #[test]
1155    fn keys_step_page_and_jump() {
1156        let full = (0., 100.);
1157        assert_eq!(stepped_value("right", 50., full, full, 1.), Some(51.));
1158        assert_eq!(stepped_value("down", 0., full, full, 1.), Some(0.));
1159        assert_eq!(stepped_value("pageup", 50., full, full, 1.), Some(60.));
1160        assert_eq!(stepped_value("end", 5., full, full, 1.), Some(100.));
1161        assert_eq!(stepped_value("a", 5., full, full, 1.), None);
1162        assert_eq!(default_format(42.0001), "42");
1163        assert_eq!(default_format(0.25), "0.2");
1164    }
1165
1166    #[test]
1167    fn page_keys_stay_on_the_step_grid() {
1168        let full = (0., 100.);
1169        // A tenth of 0..100 is 10, off a step-3 grid: page by 9 instead.
1170        assert_eq!(stepped_value("pageup", 0., full, full, 3.), Some(9.));
1171        assert_eq!(stepped_value("pagedown", 99., full, full, 3.), Some(90.));
1172        // The grid counts from min.
1173        assert_eq!(
1174            stepped_value("right", 7., (2., 52.), (2., 52.), 5.),
1175            Some(12.)
1176        );
1177        // A range thumb stops at the other thumb.
1178        assert_eq!(stepped_value("end", 10., (0., 40.), full, 1.), Some(40.));
1179    }
1180
1181    #[test]
1182    fn an_invalid_range_does_not_panic() {
1183        assert_eq!(stepped_value("right", 5., (10., 0.), (10., 0.), 1.), None);
1184        assert_eq!(
1185            stepped_value("right", f32::NAN, (0., 1.), (0., 1.), 1.),
1186            None
1187        );
1188        assert_eq!(
1189            stepped_value("right", 0.5, (0., f32::NAN), (0., f32::NAN), 1.),
1190            None
1191        );
1192    }
1193
1194    /// A slider configured by `configure`, over `state`.
1195    struct Configured(Entity<SliderState>, fn(MaterialSlider) -> MaterialSlider);
1196    impl Render for Configured {
1197        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1198            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1199            div().w(px(240.)).h(px(240.)).p(px(60.)).child(
1200                div()
1201                    .size_full()
1202                    .debug_selector(|| "slider".into())
1203                    .child((self.1)(MaterialSlider::new(&self.0, "Level"))),
1204            )
1205        }
1206    }
1207
1208    fn configured(
1209        cx: &mut TestAppContext,
1210        state: SliderState,
1211        configure: fn(MaterialSlider) -> MaterialSlider,
1212    ) -> (Entity<SliderState>, &mut VisualTestContext) {
1213        cx.update(|cx| {
1214            gpui_base::init(cx);
1215            cx.set_reduce_motion(true);
1216        });
1217        let (view, cx) = cx.add_window_view(|_, cx| Configured(cx.new(|_| state), configure));
1218        draw(cx);
1219        let state = view.read_with(cx, |v, _| v.0.clone());
1220        (state, cx)
1221    }
1222
1223    fn count_changes(state: &Entity<SliderState>, cx: &mut VisualTestContext) -> Rc<Cell<usize>> {
1224        let changes = Rc::new(Cell::new(0));
1225        cx.update(|_, cx| {
1226            let changes = changes.clone();
1227            cx.subscribe(state, move |_, event: &SliderEvent, _| {
1228                if matches!(event, SliderEvent::Change(_)) {
1229                    changes.set(changes.get() + 1)
1230                }
1231            })
1232            .detach();
1233        });
1234        changes
1235    }
1236
1237    #[gpui::test]
1238    fn a_classic_slider_is_tabbable_and_steps_with_arrows(cx: &mut TestAppContext) {
1239        let (state, cx) = configured(cx, SliderState::new().max(100.).default_value(30.), |s| {
1240            s.appearance(SliderAppearance::Classic)
1241        });
1242        cx.update(|window, cx| window.focus_next(cx));
1243        cx.simulate_keystrokes("right right");
1244        assert_eq!(state.read_with(cx, |s, _| s.value().end()), 32.);
1245    }
1246
1247    #[gpui::test]
1248    fn a_vertical_classic_slider_follows_its_axis(cx: &mut TestAppContext) {
1249        let (_, cx) = configured(cx, SliderState::new().max(100.).default_value(30.), |s| {
1250            s.appearance(SliderAppearance::Classic).vertical()
1251        });
1252        let bounds = cx.debug_bounds("slider").unwrap();
1253        let slider = cx.update(|window, _| {
1254            gpui_base::test_support::snapshots(window)
1255                .into_iter()
1256                .find(|s| s.role() == Some(Role::Slider))
1257                .unwrap()
1258        });
1259        assert_eq!(slider.bounds().size.width, px(48.), "a 48dp wide target");
1260        assert_eq!(slider.bounds().size.height, bounds.size.height);
1261    }
1262
1263    #[gpui::test]
1264    fn keyboard_focus_lands_on_the_slider_node(cx: &mut TestAppContext) {
1265        let (_, cx) = configured(cx, SliderState::new().max(100.).default_value(30.), |s| s);
1266        cx.update(|window, cx| window.focus_next(cx));
1267        draw(cx);
1268        let slider = cx.update(|window, _| {
1269            gpui_base::test_support::snapshots(window)
1270                .into_iter()
1271                .find(|s| s.role() == Some(Role::Slider))
1272                .unwrap()
1273        });
1274        assert_eq!(slider.label(), Some("Level"));
1275        assert_eq!(slider.focused(), Some(true), "{slider:?}");
1276    }
1277
1278    #[gpui::test]
1279    fn tab_moves_between_range_thumbs_and_each_takes_arrows(cx: &mut TestAppContext) {
1280        let (state, cx) = configured(
1281            cx,
1282            SliderState::new().max(100.).default_value((20., 60.)),
1283            |s| s,
1284        );
1285        cx.update(|window, cx| window.focus_next(cx));
1286        cx.simulate_keystrokes("right");
1287        assert_eq!(state.read_with(cx, |s, _| s.value()), (21., 60.).into());
1288        cx.update(|window, cx| window.focus_next(cx));
1289        cx.simulate_keystrokes("left left");
1290        assert_eq!(state.read_with(cx, |s, _| s.value()), (21., 58.).into());
1291    }
1292
1293    #[gpui::test]
1294    fn keys_at_an_end_report_no_change(cx: &mut TestAppContext) {
1295        let (state, cx) = configured(cx, SliderState::new().max(100.).default_value(100.), |s| s);
1296        let changes = count_changes(&state, cx);
1297        cx.update(|window, cx| window.focus_next(cx));
1298        cx.simulate_keystrokes("right end pageup");
1299        assert_eq!(changes.get(), 0);
1300        cx.simulate_keystrokes("left");
1301        assert_eq!(changes.get(), 1);
1302    }
1303
1304    #[gpui::test]
1305    fn only_the_primary_button_presses_the_slider(cx: &mut TestAppContext) {
1306        let (_, cx) = configured(cx, SliderState::new().max(100.).default_value(30.), |s| s);
1307        let thumb_width = |cx: &mut VisualTestContext| {
1308            cx.update(|window, _| {
1309                gpui_base::test_support::find(window, &[], &ElementId::from(("slider-thumb", 0u32)))
1310                    .unwrap()
1311                    .bounds()
1312                    .size
1313                    .width
1314            })
1315        };
1316        let resting = thumb_width(cx);
1317        assert_eq!(resting, px(HANDLE_WIDTH));
1318        let at = cx.debug_bounds("slider").unwrap().center();
1319        cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::default());
1320        cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::default());
1321        draw(cx);
1322        assert_eq!(thumb_width(cx), resting, "a right click left it pressed");
1323    }
1324
1325    struct SliderHarness(Entity<SliderState>, Option<InteractionPreview>);
1326    impl Render for SliderHarness {
1327        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1328            window.set_rem_size(px(14.));
1329            let slider = MaterialSlider::new(&self.0, "Level");
1330            let slider = match self.1 {
1331                Some(state) => slider.preview_state(state),
1332                None => slider,
1333            };
1334            div()
1335                .w(px(200.))
1336                .p(px(60.))
1337                .child(div().debug_selector(|| "slider".into()).child(slider))
1338        }
1339    }
1340
1341    fn draw(cx: &mut VisualTestContext) {
1342        cx.update(|w, cx| {
1343            w.refresh();
1344            w.draw(cx).clear(cx);
1345        });
1346    }
1347
1348    #[gpui::test]
1349    fn keyboard_steps_the_value_and_emits_changes(cx: &mut TestAppContext) {
1350        cx.update(gpui_base::init);
1351        let (view, cx) = cx.add_window_view(|_, cx| {
1352            SliderHarness(
1353                cx.new(|_| SliderState::new().max(100.).default_value(30.)),
1354                None,
1355            )
1356        });
1357        draw(cx);
1358        let state = view.read_with(cx, |v, _| v.0.clone());
1359        let changes = std::rc::Rc::new(std::cell::Cell::new(0));
1360        cx.update(|_, cx| {
1361            let changes = changes.clone();
1362            cx.subscribe(&state, move |_, _: &SliderEvent, _| {
1363                changes.set(changes.get() + 1)
1364            })
1365            .detach();
1366        });
1367        let bounds = cx.debug_bounds("slider").unwrap();
1368        cx.simulate_click(bounds.center(), Modifiers::default());
1369        draw(cx);
1370        let before = state.read_with(cx, |s, _| s.value().end());
1371        cx.simulate_keystrokes("right right");
1372        draw(cx);
1373        let after = state.read_with(cx, |s, _| s.value().end());
1374        assert_eq!(after, before + 2.);
1375        cx.simulate_keystrokes("end");
1376        assert_eq!(state.read_with(cx, |s, _| s.value().end()), 100.);
1377        assert!(changes.get() >= 3);
1378    }
1379
1380    #[gpui::test]
1381    fn a_pressed_specimen_shows_the_narrow_handle(cx: &mut TestAppContext) {
1382        cx.update(gpui_base::init);
1383        let (_, cx) = cx.add_window_view(|_, cx| {
1384            SliderHarness(
1385                cx.new(|_| SliderState::new().max(100.).default_value(30.)),
1386                Some(InteractionPreview::Pressed),
1387            )
1388        });
1389        draw(cx);
1390        let slider = cx.debug_bounds("slider").unwrap();
1391        assert_eq!(slider.size.height, px(44.), "the XSmall handle height");
1392    }
1393}