1use 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#[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}