1use 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#[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 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
86pub enum SliderAppearance {
87 Classic,
88 #[default]
89 Expressive,
90}
91
92#[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 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
140const 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.;
148const STOP_INSET: f32 = 6.;
150const BUBBLE_HEIGHT: f32 = 44.;
153const BUBBLE_MIN_WIDTH: f32 = 48.;
154const BUBBLE_PADDING: f32 = 16.;
155const BUBBLE_GAP: f32 = 4.;
156const ICON_MIN_FRACTION: f32 = 0.25;
158
159type 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 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 pub fn centered(mut self, centered: bool) -> Self {
223 self.centered = centered;
224 self
225 }
226 pub fn stops(mut self, stops: bool) -> Self {
228 self.stops = stops;
229 self
230 }
231 pub fn color(mut self, color: SliderColor) -> Self {
233 self.color = color;
234 self
235 }
236 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
238 self.icon = Some(icon.into());
239 self
240 }
241 pub fn value_indicator(mut self, show: bool) -> Self {
243 self.value_indicator = show;
244 self
245 }
246 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 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#[derive(Clone, Copy, Debug, PartialEq)]
275struct SliderPaint {
276 active: Rgba,
277 inactive: Rgba,
278 handle: Rgba,
279 on_active: Rgba,
281 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#[derive(Clone, Copy, Debug, PartialEq)]
310struct Segment {
311 start: f32,
312 end: f32,
313 active: bool,
314 outer_start: bool,
316 outer_end: bool,
317}
318
319fn handle_center(length: f32, handle: f32, fraction: f32) -> f32 {
322 handle / 2. + fraction.clamp(0., 1.) * (length - handle).max(0.)
323}
324
325fn 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
372fn 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#[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 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 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
512fn 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
522const CLASSIC_TARGET: f32 = 48.;
527const CLASSIC_STATE_LAYER: f32 = 40.;
528
529fn 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 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 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 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 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 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 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 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 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 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 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
984fn 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 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 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 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 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 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 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 assert_eq!(stepped_value("pageup", 0., full, full, 3.), Some(9.));
1171 assert_eq!(stepped_value("pagedown", 99., full, full, 3.), Some(90.));
1172 assert_eq!(
1174 stepped_value("right", 7., (2., 52.), (2., 52.), 5.),
1175 Some(12.)
1176 );
1177 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 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}