Skip to main content

gpui_rhai/
range_slider.rs

1//! Controlled two-thumb range slider using the shared range axis math.
2
3use std::collections::BTreeMap;
4
5use gpui::{
6    AnyElement, App, AppContext, Bounds, Context, CursorStyle, Element, ElementId, Entity,
7    FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
8    KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
9    Role, StatefulInteractiveElement, Styled, Window, div, px, relative,
10};
11
12use crate::range_input::{
13    RangeOrientation, fraction_f32, horizontal_thumb_ratio, normalize_value, range_value_at,
14};
15use crate::{
16    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
17    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps,
18    PrimitiveTheme, Style, TextDirection, UiValue, ValueSchema,
19};
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22enum RangeThumb {
23    Low,
24    High,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq)]
28struct RangePair {
29    low: f64,
30    high: f64,
31}
32
33#[derive(Clone)]
34struct RangeSliderConfig {
35    values: RangePair,
36    min: f64,
37    max: f64,
38    step: f64,
39    minimum_gap: f64,
40    orientation: RangeOrientation,
41    disabled: bool,
42    low_label: String,
43    high_label: String,
44    track_style: Style,
45    fill_style: Style,
46    thumb_style: Style,
47    theme: PrimitiveTheme,
48}
49
50struct RangeSliderEntity {
51    low_focus: FocusHandle,
52    high_focus: FocusHandle,
53    controlled: RangePair,
54    preview: RangePair,
55    min: f64,
56    max: f64,
57    step: f64,
58    minimum_gap: f64,
59    orientation: RangeOrientation,
60    disabled: bool,
61    dragging: bool,
62    active: RangeThumb,
63    bounds: Option<Bounds<Pixels>>,
64    events: PrimitiveContext,
65    interaction_key: String,
66    low_label: String,
67    high_label: String,
68    track_style: Style,
69    fill_style: Style,
70    thumb_style: Style,
71    theme: PrimitiveTheme,
72    revision: u64,
73}
74
75impl RangeSliderEntity {
76    fn new(
77        config: RangeSliderConfig,
78        events: PrimitiveContext,
79        interaction_key: String,
80        cx: &mut Context<Self>,
81    ) -> Self {
82        let values = normalize_pair(config.values, &config);
83        Self {
84            low_focus: cx.focus_handle(),
85            high_focus: cx.focus_handle(),
86            controlled: values,
87            preview: values,
88            min: config.min,
89            max: config.max,
90            step: config.step,
91            minimum_gap: config.minimum_gap,
92            orientation: config.orientation,
93            disabled: config.disabled,
94            dragging: false,
95            active: RangeThumb::Low,
96            bounds: None,
97            events,
98            interaction_key,
99            low_label: config.low_label,
100            high_label: config.high_label,
101            track_style: config.track_style,
102            fill_style: config.fill_style,
103            thumb_style: config.thumb_style,
104            theme: config.theme,
105            revision: 1,
106        }
107    }
108
109    fn update_props(
110        &mut self,
111        config: RangeSliderConfig,
112        events: PrimitiveContext,
113        cx: &mut Context<Self>,
114    ) -> bool {
115        let values = normalize_pair(config.values, &config);
116        let changed = self.controlled != values
117            || self.min.to_bits() != config.min.to_bits()
118            || self.max.to_bits() != config.max.to_bits()
119            || self.step.to_bits() != config.step.to_bits()
120            || self.minimum_gap.to_bits() != config.minimum_gap.to_bits()
121            || self.orientation != config.orientation
122            || self.disabled != config.disabled;
123        if changed {
124            self.revision = self.revision.saturating_add(1);
125            self.preview = values;
126            self.dragging = false;
127        }
128        self.controlled = values;
129        if !self.dragging || config.disabled {
130            self.preview = values;
131        }
132        self.min = config.min;
133        self.max = config.max;
134        self.step = config.step;
135        self.minimum_gap = config.minimum_gap;
136        self.orientation = config.orientation;
137        self.disabled = config.disabled;
138        self.dragging &= !config.disabled;
139        self.events = events;
140        self.low_label = config.low_label;
141        self.high_label = config.high_label;
142        self.track_style = config.track_style;
143        self.fill_style = config.fill_style;
144        self.thumb_style = config.thumb_style;
145        self.theme = config.theme;
146        cx.notify();
147        changed
148    }
149
150    fn ratio(&self, value: f64) -> f64 {
151        ((value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
152    }
153
154    fn value_at(&self, position: Point<Pixels>) -> f64 {
155        self.bounds.map_or(self.preview.low, |bounds| {
156            range_value_at(
157                bounds,
158                position,
159                self.orientation,
160                self.theme.direction(),
161                self.min,
162                self.max,
163                self.step,
164            )
165        })
166    }
167
168    fn nearest_thumb(&self, value: f64) -> RangeThumb {
169        let low_distance = (value - self.preview.low).abs();
170        let high_distance = (value - self.preview.high).abs();
171        if low_distance < high_distance {
172            RangeThumb::Low
173        } else if high_distance < low_distance {
174            RangeThumb::High
175        } else {
176            self.active
177        }
178    }
179
180    fn set_active_value(&mut self, value: f64) {
181        match self.active {
182            RangeThumb::Low => {
183                self.preview.low = normalize_constrained_value(
184                    value,
185                    self.min,
186                    self.min,
187                    self.preview.high - self.minimum_gap,
188                    self.max,
189                    self.step,
190                );
191            }
192            RangeThumb::High => {
193                self.preview.high = normalize_constrained_value(
194                    value,
195                    self.min,
196                    self.preview.low + self.minimum_gap,
197                    self.max,
198                    self.max,
199                    self.step,
200                );
201            }
202        }
203    }
204
205    fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
206        if self.disabled {
207            return;
208        }
209        let value = self.value_at(event.position);
210        self.active = self.nearest_thumb(value);
211        match self.active {
212            RangeThumb::Low => self.low_focus.focus(window, cx),
213            RangeThumb::High => self.high_focus.focus(window, cx),
214        }
215        self.dragging = true;
216        let revision = self.revision;
217        self.set_active_value(value);
218        let entity = cx.entity();
219        let update_entity = entity.clone();
220        let update =
221            move |gesture: crate::interaction::GestureUpdate, _: &mut Window, cx: &mut App| {
222                let current = update_entity.update(cx, |slider, cx| {
223                    if slider.disabled || slider.revision != revision {
224                        return false;
225                    }
226                    slider.set_active_value(slider.value_at(gesture.current()));
227                    cx.notify();
228                    true
229                });
230                if current {
231                    crate::interaction::InteractionFlow::Continue
232                } else {
233                    crate::interaction::InteractionFlow::Cancel
234                }
235            };
236        let finish_entity = entity.clone();
237        let finish =
238            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
239                finish_entity.update(cx, |slider, cx| {
240                    if !slider.disabled && slider.revision == revision {
241                        slider.set_active_value(slider.value_at(gesture.current()));
242                        slider.emit_change(window, cx);
243                    }
244                    slider.dragging = false;
245                    slider.preview = slider.controlled;
246                    cx.notify();
247                });
248            };
249        let cancel = move |_: &mut Window, cx: &mut App| {
250            entity.update(cx, |slider, cx| {
251                slider.dragging = false;
252                slider.preview = slider.controlled;
253                cx.notify();
254            });
255        };
256        let owner = self.events.interaction_owner(&self.interaction_key);
257        self.events.begin_interaction(
258            crate::interaction::NativeGesture::new(
259                owner,
260                event.position,
261                cx.entity_id(),
262                update,
263                finish,
264                cancel,
265            ),
266            window,
267            cx,
268        );
269        cx.stop_propagation();
270    }
271
272    fn keyboard(
273        &mut self,
274        thumb: RangeThumb,
275        event: &KeyDownEvent,
276        window: &mut Window,
277        cx: &mut Context<Self>,
278    ) {
279        if self.disabled {
280            return;
281        }
282        self.active = thumb;
283        let key = event.keystroke.key.as_str();
284        let direction = self.theme.direction();
285        let delta = match (self.orientation, key, direction) {
286            (RangeOrientation::Horizontal, "left", TextDirection::LeftToRight)
287            | (RangeOrientation::Horizontal, "right", TextDirection::RightToLeft)
288            | (RangeOrientation::Vertical, "down", _) => Some(-self.step),
289            (RangeOrientation::Horizontal, "right", TextDirection::LeftToRight)
290            | (RangeOrientation::Horizontal, "left", TextDirection::RightToLeft)
291            | (RangeOrientation::Vertical, "up", _) => Some(self.step),
292            _ => None,
293        };
294        let current = match thumb {
295            RangeThumb::Low => self.preview.low,
296            RangeThumb::High => self.preview.high,
297        };
298        let next = if key == "home" {
299            Some(match thumb {
300                RangeThumb::Low => self.min,
301                RangeThumb::High => self.preview.low + self.minimum_gap,
302            })
303        } else if key == "end" {
304            Some(match thumb {
305                RangeThumb::Low => self.preview.high - self.minimum_gap,
306                RangeThumb::High => self.max,
307            })
308        } else {
309            delta.map(|delta| current + delta)
310        };
311        if let Some(next) = next {
312            let before = self.preview;
313            self.set_active_value(next);
314            if self.preview != before {
315                self.emit_change(window, cx);
316            }
317            cx.stop_propagation();
318            cx.notify();
319        }
320    }
321
322    fn emit_change(&self, window: &mut Window, cx: &mut App) {
323        self.events
324            .propose("change", pair_value(self.preview), window, cx);
325    }
326}
327
328impl Render for RangeSliderEntity {
329    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
330        let owner = self.events.interaction_owner(&self.interaction_key);
331        self.events.present_interaction(owner.clone());
332        if self.disabled {
333            self.events.cancel_interaction(&owner, window, cx);
334        }
335        let low_ratio = self.ratio(self.preview.low);
336        let high_ratio = self.ratio(self.preview.high);
337        let direction = self.theme.direction();
338        let track = crate::renderer::apply_style_override(
339            div().relative(),
340            &self.track_style,
341            &self.theme,
342            direction,
343        );
344        let mut fill = crate::renderer::apply_style_override(
345            div().absolute(),
346            &self.fill_style,
347            &self.theme,
348            direction,
349        );
350        let low_thumb = self.thumb(RangeThumb::Low, low_ratio, cx);
351        let high_thumb = self.thumb(RangeThumb::High, high_ratio, cx);
352        let track = match self.orientation {
353            RangeOrientation::Horizontal => {
354                let low_visual = horizontal_thumb_ratio(low_ratio, direction);
355                let high_visual = horizontal_thumb_ratio(high_ratio, direction);
356                let start = low_visual.min(high_visual);
357                let span = (high_visual - low_visual).abs();
358                fill = fill
359                    .left(relative(fraction_f32(start)))
360                    .top(px(0.0))
361                    .bottom(px(0.0))
362                    .w(relative(fraction_f32(span)));
363                track.child(fill).child(low_thumb).child(high_thumb)
364            }
365            RangeOrientation::Vertical => {
366                fill = fill
367                    .bottom(relative(fraction_f32(low_ratio)))
368                    .left(px(0.0))
369                    .right(px(0.0))
370                    .h(relative(fraction_f32(high_ratio - low_ratio)));
371                track.child(fill).child(low_thumb).child(high_thumb)
372            }
373        };
374        div()
375            .id("gpui-rhai-range-slider")
376            .relative()
377            .size_full()
378            .flex()
379            .items_center()
380            .justify_center()
381            .cursor(if self.disabled {
382                CursorStyle::OperationNotAllowed
383            } else {
384                CursorStyle::PointingHand
385            })
386            .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
387            .child(track)
388            .child(RangeSliderBoundsRecorder {
389                slider: cx.entity(),
390            })
391            .opacity(if self.disabled { 0.62 } else { 1.0 })
392    }
393}
394
395impl RangeSliderEntity {
396    fn thumb(
397        &self,
398        thumb: RangeThumb,
399        ratio: f64,
400        cx: &mut Context<Self>,
401    ) -> gpui::Stateful<gpui::Div> {
402        let mut element = crate::renderer::apply_style_override(
403            div().absolute(),
404            &self.thumb_style,
405            &self.theme,
406            self.theme.direction(),
407        );
408        element = match self.orientation {
409            RangeOrientation::Horizontal => element
410                .left(relative(fraction_f32(horizontal_thumb_ratio(
411                    ratio,
412                    self.theme.direction(),
413                ))))
414                .top(relative(0.5))
415                .ml(px(-7.0))
416                .mt(px(-7.0)),
417            RangeOrientation::Vertical => element
418                .bottom(relative(fraction_f32(ratio)))
419                .left(relative(0.5))
420                .mb(px(-7.0))
421                .ml(px(-7.0)),
422        };
423        let (focus, label, value) = match thumb {
424            RangeThumb::Low => (&self.low_focus, self.low_label.clone(), self.preview.low),
425            RangeThumb::High => (&self.high_focus, self.high_label.clone(), self.preview.high),
426        };
427        let entity = cx.entity();
428        element
429            .id(match thumb {
430                RangeThumb::Low => "gpui-rhai-range-slider-low",
431                RangeThumb::High => "gpui-rhai-range-slider-high",
432            })
433            .track_focus(&focus.clone().tab_stop(!self.disabled))
434            .role(Role::Slider)
435            .aria_label(label)
436            .aria_numeric_value(value)
437            .aria_min_numeric_value(self.min)
438            .aria_max_numeric_value(self.max)
439            .aria_numeric_value_step(self.step)
440            .on_key_down(move |event, window, cx| {
441                entity.update(cx, |slider, cx| {
442                    slider.keyboard(thumb, event, window, cx);
443                });
444            })
445    }
446}
447
448struct RangeSliderBoundsRecorder {
449    slider: Entity<RangeSliderEntity>,
450}
451
452impl Element for RangeSliderBoundsRecorder {
453    type RequestLayoutState = AnyElement;
454    type PrepaintState = ();
455
456    fn id(&self) -> Option<ElementId> {
457        None
458    }
459
460    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
461        None
462    }
463
464    fn request_layout(
465        &mut self,
466        _: Option<&GlobalElementId>,
467        _: Option<&InspectorElementId>,
468        window: &mut Window,
469        cx: &mut App,
470    ) -> (LayoutId, Self::RequestLayoutState) {
471        let mut child = div()
472            .absolute()
473            .top(px(0.0))
474            .right(px(0.0))
475            .bottom(px(0.0))
476            .left(px(0.0))
477            .into_any_element();
478        let layout = child.request_layout(window, cx);
479        (layout, child)
480    }
481
482    fn prepaint(
483        &mut self,
484        _: Option<&GlobalElementId>,
485        _: Option<&InspectorElementId>,
486        bounds: Bounds<Pixels>,
487        child: &mut Self::RequestLayoutState,
488        window: &mut Window,
489        cx: &mut App,
490    ) {
491        child.prepaint(window, cx);
492        self.slider
493            .update(cx, |slider, _| slider.bounds = Some(bounds));
494    }
495
496    fn paint(
497        &mut self,
498        _: Option<&GlobalElementId>,
499        _: Option<&InspectorElementId>,
500        _: Bounds<Pixels>,
501        child: &mut Self::RequestLayoutState,
502        (): &mut Self::PrepaintState,
503        window: &mut Window,
504        cx: &mut App,
505    ) {
506        child.paint(window, cx);
507    }
508}
509
510impl IntoElement for RangeSliderBoundsRecorder {
511    type Element = Self;
512
513    fn into_element(self) -> Self::Element {
514        self
515    }
516}
517
518#[derive(Default)]
519pub struct RangeSliderPrimitiveHandler {
520    instances: BTreeMap<PrimitiveInstanceId, Entity<RangeSliderEntity>>,
521}
522
523impl PrimitiveHandler for RangeSliderPrimitiveHandler {
524    fn render(
525        &mut self,
526        instance: &PrimitiveInstance,
527        events: &PrimitiveContext,
528        theme: &PrimitiveTheme,
529        window: &mut Window,
530        cx: &mut App,
531    ) -> Result<AnyElement, String> {
532        let id = instance
533            .id
534            .clone()
535            .ok_or_else(|| "RangeSliderPrimitive requires a stable key".to_owned())?;
536        let config = parse_config(&instance.node.props, theme)?;
537        let entity = if let Some(entity) = self.instances.get(&id) {
538            entity.clone()
539        } else {
540            let events = events.clone();
541            let interaction_key = format!("{}:{}", id.key(), id.node());
542            let entity =
543                cx.new(|cx| RangeSliderEntity::new(config.clone(), events, interaction_key, cx));
544            self.instances.insert(id.clone(), entity.clone());
545            entity
546        };
547        let changed = entity.update(cx, |slider, cx| {
548            slider.update_props(config, events.clone(), cx)
549        });
550        if changed {
551            let owner = events.interaction_owner(&format!("{}:{}", id.key(), id.node()));
552            events.cancel_interaction(&owner, window, cx);
553        }
554        Ok(entity.into_any_element())
555    }
556
557    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
558        self.instances.remove(instance);
559    }
560}
561
562fn parse_config(
563    props: &PrimitiveProps,
564    theme: &PrimitiveTheme,
565) -> Result<RangeSliderConfig, String> {
566    let min = props.number("min").unwrap_or(0.0);
567    let max = props.number("max").unwrap_or(100.0);
568    let step = props.number("step").unwrap_or(1.0);
569    let minimum_gap = props.number("minimum_gap").unwrap_or(0.0);
570    let values = RangePair {
571        low: props
572            .number("low")
573            .ok_or_else(|| "range slider low is required".to_owned())?,
574        high: props
575            .number("high")
576            .ok_or_else(|| "range slider high is required".to_owned())?,
577    };
578    if ![min, max, step, minimum_gap, values.low, values.high]
579        .into_iter()
580        .all(f64::is_finite)
581        || max <= min
582        || step <= 0.0
583        || minimum_gap < 0.0
584        || minimum_gap > max - min
585        || values.low < min
586        || values.high > max
587        || values.low + minimum_gap > values.high
588    {
589        return Err("range slider values, step, bounds, or minimum_gap are invalid".to_owned());
590    }
591    let orientation = match props.string("orientation") {
592        None | Some("horizontal") => RangeOrientation::Horizontal,
593        Some("vertical") => RangeOrientation::Vertical,
594        Some(other) => return Err(format!("unknown range slider orientation `{other}`")),
595    };
596    Ok(RangeSliderConfig {
597        values,
598        min,
599        max,
600        step,
601        minimum_gap,
602        orientation,
603        disabled: props.boolean("disabled").unwrap_or(false),
604        low_label: props.string("low_label").unwrap_or("Minimum").to_owned(),
605        high_label: props.string("high_label").unwrap_or("Maximum").to_owned(),
606        track_style: props.style("track_style").cloned().unwrap_or_default(),
607        fill_style: props.style("fill_style").cloned().unwrap_or_default(),
608        thumb_style: props.style("thumb_style").cloned().unwrap_or_default(),
609        theme: theme.clone(),
610    })
611}
612
613fn normalize_pair(values: RangePair, config: &RangeSliderConfig) -> RangePair {
614    normalize_pair_values(
615        values,
616        config.min,
617        config.max,
618        config.step,
619        config.minimum_gap,
620    )
621}
622
623fn normalize_pair_values(
624    values: RangePair,
625    min: f64,
626    max: f64,
627    step: f64,
628    minimum_gap: f64,
629) -> RangePair {
630    let low = normalize_value(values.low, min, max, step);
631    let high = normalize_value(values.high, min, max, step);
632    if high - low >= minimum_gap {
633        return RangePair { low, high };
634    }
635    let raise_high = first_step_at_or_above(low + minimum_gap, min, max, step)
636        .map(|high| RangePair { low, high });
637    let lower_low = last_step_at_or_below(high - minimum_gap, min, max, step)
638        .map(|low| RangePair { low, high });
639    [raise_high, lower_low]
640        .into_iter()
641        .flatten()
642        .min_by(|left, right| {
643            pair_distance(*left, values)
644                .partial_cmp(&pair_distance(*right, values))
645                .unwrap_or(std::cmp::Ordering::Equal)
646        })
647        .unwrap_or(RangePair {
648            low: min,
649            high: max,
650        })
651}
652
653fn first_step_at_or_above(value: f64, origin: f64, max: f64, step: f64) -> Option<f64> {
654    let stepped = origin + ((value - origin) / step).ceil() * step;
655    if stepped <= max {
656        Some(stepped.max(origin))
657    } else {
658        (max >= value).then_some(max)
659    }
660}
661
662fn last_step_at_or_below(value: f64, min: f64, max: f64, step: f64) -> Option<f64> {
663    let stepped = min + ((value - min) / step).floor() * step;
664    if stepped >= min {
665        Some(stepped.min(max))
666    } else {
667        (min <= value).then_some(min)
668    }
669}
670
671fn pair_distance(pair: RangePair, source: RangePair) -> f64 {
672    (pair.low - source.low).abs() + (pair.high - source.high).abs()
673}
674
675fn normalize_constrained_value(
676    value: f64,
677    origin: f64,
678    feasible_min: f64,
679    feasible_max: f64,
680    global_max: f64,
681    step: f64,
682) -> f64 {
683    let snapped = normalize_value(value, origin, global_max, step);
684    if snapped >= feasible_min && snapped <= feasible_max {
685        return snapped;
686    }
687    let first = origin + ((feasible_min - origin) / step).ceil() * step;
688    let last = origin + ((feasible_max - origin) / step).floor() * step;
689    if first <= last {
690        snapped.clamp(first, last)
691    } else {
692        value.clamp(feasible_min, feasible_max)
693    }
694}
695
696fn pair_value(values: RangePair) -> UiValue {
697    UiValue::Map(BTreeMap::from([
698        ("low".to_owned(), UiValue::Float(values.low)),
699        ("high".to_owned(), UiValue::Float(values.high)),
700    ]))
701}
702
703fn pair_schema() -> ValueSchema {
704    ValueSchema::object(BTreeMap::from([
705        (
706            "low".to_owned(),
707            ObjectField::required(ValueSchema::number()),
708        ),
709        (
710            "high".to_owned(),
711            ObjectField::required(ValueSchema::number()),
712        ),
713    ]))
714}
715
716/// Build the native controlled two-thumb range slider schema.
717///
718/// # Panics
719///
720/// Panics only if the static primitive ID becomes invalid.
721#[must_use]
722pub fn range_slider_primitive_descriptor() -> PrimitiveDescriptor {
723    PrimitiveDescriptor {
724        id: PrimitiveId::parse("gpui_rhai.range_slider").expect("static primitive ID"),
725        export: "RangeSliderPrimitive".to_owned(),
726        props: BTreeMap::from([
727            (
728                "low".to_owned(),
729                ObjectField::required(ValueSchema::number()),
730            ),
731            (
732                "high".to_owned(),
733                ObjectField::required(ValueSchema::number()),
734            ),
735            (
736                "min".to_owned(),
737                ObjectField::required(ValueSchema::number()),
738            ),
739            (
740                "max".to_owned(),
741                ObjectField::required(ValueSchema::number()),
742            ),
743            (
744                "step".to_owned(),
745                ObjectField::required(ValueSchema::positive_number()),
746            ),
747            (
748                "minimum_gap".to_owned(),
749                ObjectField::optional(ValueSchema::bounded_number(Some(0.0), None)),
750            ),
751            (
752                "orientation".to_owned(),
753                ObjectField::required(ValueSchema::String {
754                    allowed: vec!["horizontal".to_owned(), "vertical".to_owned()],
755                }),
756            ),
757            (
758                "disabled".to_owned(),
759                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
760            ),
761            (
762                "low_label".to_owned(),
763                ObjectField::required(ValueSchema::string()),
764            ),
765            (
766                "high_label".to_owned(),
767                ObjectField::required(ValueSchema::string()),
768            ),
769            (
770                "track_style".to_owned(),
771                ObjectField::required(ValueSchema::Style),
772            ),
773            (
774                "fill_style".to_owned(),
775                ObjectField::required(ValueSchema::Style),
776            ),
777            (
778                "thumb_style".to_owned(),
779                ObjectField::required(ValueSchema::Style),
780            ),
781            (
782                "on_change".to_owned(),
783                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
784            ),
785        ]),
786        events: BTreeMap::from([(
787            "change".to_owned(),
788            EventSchema {
789                payload: pair_schema(),
790            },
791        )]),
792        state: ComponentStateSchema::default(),
793        lifecycle: true,
794        effect: None,
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn pair_normalization_preserves_order_and_gap() {
804        assert_eq!(
805            normalize_pair_values(
806                RangePair {
807                    low: 31.0,
808                    high: 84.0,
809                },
810                0.0,
811                100.0,
812                5.0,
813                10.0,
814            ),
815            RangePair {
816                low: 30.0,
817                high: 85.0,
818            }
819        );
820    }
821
822    #[test]
823    fn pair_normalization_solves_legal_endpoint_constraints_jointly() {
824        assert_eq!(
825            normalize_pair_values(
826                RangePair {
827                    low: 95.0,
828                    high: 100.0,
829                },
830                0.0,
831                100.0,
832                10.0,
833                5.0,
834            ),
835            RangePair {
836                low: 90.0,
837                high: 100.0,
838            }
839        );
840    }
841
842    #[test]
843    fn normalized_pairs_are_bounded_gapped_and_idempotent() {
844        for minimum in [0.0_f64, 3.0] {
845            for maximum in [97.0, 100.0] {
846                for step in [3.0, 10.0] {
847                    for gap in [0.0, 5.0, 17.0] {
848                        if gap > maximum - minimum {
849                            continue;
850                        }
851                        for low in [minimum, maximum - gap, maximum] {
852                            for high in [minimum, minimum + gap, maximum] {
853                                let source = RangePair {
854                                    low: low.min(high),
855                                    high: low.max(high),
856                                };
857                                if source.high - source.low < gap {
858                                    continue;
859                                }
860                                let normalized =
861                                    normalize_pair_values(source, minimum, maximum, step, gap);
862                                assert!(normalized.low >= minimum);
863                                assert!(normalized.high <= maximum);
864                                assert!(normalized.high - normalized.low + 1e-9 >= gap);
865                                assert_eq!(
866                                    normalize_pair_values(normalized, minimum, maximum, step, gap,),
867                                    normalized
868                                );
869                            }
870                        }
871                    }
872                }
873            }
874        }
875    }
876}