1use std::time::Duration;
90
91use frust::{FrameTime, SpringDesc};
92use kurbo::{Point, Rect, Size};
93
94use crate::interaction::HapticSignal;
95
96pub(crate) const HANDLE_HEIGHT: f64 = 44.0;
109pub(crate) const HANDLE_WIDTH: f64 = 4.0;
111pub(crate) const PRESSED_HANDLE_WIDTH: f64 = 2.0;
115pub(crate) const TRACK_HEIGHT: f64 = 16.0;
118pub(crate) const HANDLE_GAP: f64 = 6.0;
121pub(crate) const TRACK_INSIDE_CORNER: f64 = 2.0;
125pub(crate) const TRACK_CORNER_RADIUS: f64 = 8.0;
129pub(crate) const STOP_INDICATOR_SIZE: f64 = 4.0;
132pub(crate) const TICK_SIZE: f64 = 4.0;
135pub(crate) const STOP_INDICATOR_TRAILING_SPACE: f64 = 6.0;
138pub(crate) const ICON_EDGE_INSET: f64 = 8.0;
141pub(crate) const ICON_SIZE_DEFAULT: f64 = 24.0;
144pub(crate) const VALUE_INDICATOR_BOTTOM_SPACE: f64 = 12.0;
147pub(crate) const DISABLED_ACTIVE_OPACITY: f32 = 0.38;
150pub(crate) const DISABLED_INACTIVE_OPACITY: f32 = 0.12;
153pub(crate) const TRACK_ICON_SIZE: f64 = 16.0;
156pub(crate) const TRACK_ICON_INSET: f64 = 4.0;
159pub(crate) const TRACK_ICON_MIN_SLACK: f64 = 8.0;
162pub(crate) const FOCUS_HANDLE_GAP_EXTRA: f64 = 4.0;
166pub(crate) const FOCUS_STROKE: f64 = 2.0;
169pub(crate) const FOCUS_INFLATE: f64 = 6.0;
174pub(crate) const PRESS_MORPH_DURATION: Duration = Duration::from_millis(100);
177pub(crate) const HAPTIC_MIN_INTERVAL: Duration = Duration::from_millis(60);
180pub(crate) const DOCK_SPRING: SpringDesc = SpringDesc {
187 mass: 1.0,
188 stiffness: 800.0,
189 damping_ratio: 0.6,
190};
191pub(crate) const ICON_DOCK_SLACK: f64 = 8.0;
194pub(crate) const ICON_DOCK_OFFSET_SLACK: f64 = 12.0;
197pub(crate) const VALUE_INDICATOR_LEAD: f64 = 24.0;
200pub(crate) const VALUE_INDICATOR_LIFT: f64 = 24.0;
203pub(crate) const VALUE_INDICATOR_RADIUS: f64 = 4.0;
206pub(crate) const VALUE_INDICATOR_PAD_X: f64 = 8.0;
209pub(crate) const VALUE_INDICATOR_PAD_Y: f64 = 4.0;
212pub(crate) const DEFAULT_EXTENT: f64 = 200.0;
217
218#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
228pub enum SliderTrackKind {
229 #[default]
231 Standard,
232 Centered,
234 Range,
237}
238
239#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
242pub enum SliderIconPosition {
243 Start,
245 #[default]
247 End,
248}
249
250#[derive(Clone, Copy, Debug, PartialEq)]
253pub(crate) struct ValueSpec {
254 pub(crate) min: f64,
255 pub(crate) max: f64,
256 pub(crate) divisions: Option<u32>,
257}
258
259impl Default for ValueSpec {
260 fn default() -> Self {
263 Self {
264 min: 0.0,
265 max: 1.0,
266 divisions: None,
267 }
268 }
269}
270
271impl ValueSpec {
272 pub(crate) fn fraction(&self, value: f64) -> f64 {
276 if self.max <= self.min {
277 return 0.0;
278 }
279 ((value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
280 }
281
282 pub(crate) fn value_from_fraction(&self, fraction: f64) -> f64 {
285 self.min + fraction.clamp(0.0, 1.0) * (self.max - self.min)
286 }
287
288 pub(crate) fn snap(&self, value: f64) -> f64 {
305 if !(self.min.is_finite() && self.max.is_finite() && self.max > self.min) {
306 return if self.min.is_finite() { self.min } else { 0.0 };
307 }
308 if value.is_nan() {
309 return self.min;
310 }
311 let clamped = value.clamp(self.min, self.max);
312 let Some(divisions) = self.divisions.filter(|d| *d > 0) else {
313 return clamped;
314 };
315 let step = (self.max - self.min) / f64::from(divisions);
316 self.min + ((clamped - self.min) / step).round() * step
317 }
318
319 pub(crate) fn tick_fractions(&self) -> Vec<f64> {
323 let Some(divisions) = self.divisions.filter(|d| *d > 0) else {
324 return Vec::new();
325 };
326 (0..=divisions)
327 .map(|i| f64::from(i) / f64::from(divisions))
328 .collect()
329 }
330
331 pub(crate) fn value_from_offset(&self, primary: f64, extent: f64, reverse: bool) -> f64 {
335 if extent <= 0.0 {
336 return self.min;
337 }
338 let mut fraction = (primary / extent).clamp(0.0, 1.0);
339 if reverse {
340 fraction = 1.0 - fraction;
341 }
342 self.snap(self.value_from_fraction(fraction))
343 }
344}
345
346pub(crate) fn accept_value(spec: &ValueSpec, current: f64, raw: f64) -> Option<f64> {
350 let next = spec.snap(raw);
351 if next == current { None } else { Some(next) }
352}
353
354#[derive(Clone, Copy, Debug, PartialEq)]
357pub(crate) struct SliderGeometry {
358 pub(crate) extent: f64,
360 pub(crate) active_start_fraction: f64,
363 pub(crate) active_end_fraction: f64,
365 pub(crate) reverse: bool,
367 pub(crate) kind: SliderTrackKind,
368 pub(crate) handle_thickness: f64,
372 pub(crate) handle_gap: f64,
373 pub(crate) track_thickness: f64,
374 pub(crate) corner_radius: f64,
375}
376
377#[derive(Clone, Copy, Debug, PartialEq)]
387pub(crate) struct TrackMetrics {
388 pub(crate) slider_start: f64,
389 pub(crate) slider_end: f64,
390 pub(crate) centered: bool,
391 pub(crate) range: bool,
392 pub(crate) corner: f64,
393 pub(crate) inside_corner: f64,
394 pub(crate) start_gap: f64,
395 pub(crate) end_gap: f64,
396 pub(crate) value_start: f64,
397 pub(crate) value_end: f64,
398 pub(crate) center_axis: f64,
399 pub(crate) adjusted_value_start: f64,
400 pub(crate) adjusted_value_end: f64,
401 pub(crate) active_start: f64,
402 pub(crate) active_end: f64,
403}
404
405#[derive(Clone, Copy, Debug, PartialEq, Eq)]
407pub(crate) enum SegmentRole {
408 Active,
409 Inactive,
410}
411
412#[derive(Clone, Copy, Debug, PartialEq)]
415pub(crate) struct TrackSegment {
416 pub(crate) start: f64,
417 pub(crate) end: f64,
418 pub(crate) start_corner: f64,
419 pub(crate) end_corner: f64,
420 pub(crate) role: SegmentRole,
421}
422
423impl TrackMetrics {
424 pub(crate) fn resolve(geometry: &SliderGeometry) -> Option<Self> {
434 let slider_start = 0.0;
435 let slider_end = geometry.extent;
436 let span = slider_end - slider_start;
437 if span <= 0.0 {
438 return None;
439 }
440 let centered = geometry.kind == SliderTrackKind::Centered;
441 let range = geometry.kind == SliderTrackKind::Range;
442 let corner = geometry.corner_radius.min(geometry.track_thickness / 2.0);
443 let start_gap = if centered || range {
444 geometry.handle_thickness / 2.0 + geometry.handle_gap
445 } else {
446 0.0
447 };
448 let end_gap = geometry.handle_thickness / 2.0 + geometry.handle_gap;
449
450 let value_start = slider_start + span * geometry.active_start_fraction.clamp(0.0, 1.0);
451 let value_end = slider_start + span * geometry.active_end_fraction.clamp(0.0, 1.0);
452 let center_axis = (slider_start + slider_end) / 2.0;
453
454 let adjusted_value_end = if centered {
455 value_end.min(center_axis)
456 } else {
457 value_start
458 };
459 let adjusted_value_start = if centered {
460 value_end.max(center_axis)
461 } else {
462 value_end
463 };
464 let active_start = if centered {
465 adjusted_value_end
466 + if adjusted_value_end < center_axis {
467 start_gap
468 } else {
469 0.0
470 }
471 } else if range {
472 value_start + start_gap
475 } else {
476 slider_start
477 };
478 let active_end = if centered {
479 adjusted_value_start
480 - if adjusted_value_start > center_axis {
481 end_gap
482 } else {
483 0.0
484 }
485 } else {
486 value_end - end_gap
487 };
488
489 Some(Self {
490 slider_start,
491 slider_end,
492 centered,
493 range,
494 corner,
495 inside_corner: TRACK_INSIDE_CORNER,
496 start_gap,
497 end_gap,
498 value_start,
499 value_end,
500 center_axis,
501 adjusted_value_start,
502 adjusted_value_end,
503 active_start,
504 active_end,
505 })
506 }
507
508 pub(crate) fn segments(&self, rtl: bool) -> Vec<TrackSegment> {
516 let mut out = Vec::with_capacity(3);
517 let (outer, inner) = (self.corner, self.inside_corner);
518
519 if (self.centered || self.range)
523 && self.adjusted_value_end > self.slider_start + self.start_gap + outer
524 {
525 let start = self.slider_start;
526 let end = self.adjusted_value_end - self.start_gap;
527 if end > start {
528 out.push(TrackSegment {
529 start,
530 end,
531 start_corner: if rtl { inner } else { outer },
532 end_corner: if rtl { outer } else { inner },
533 role: SegmentRole::Inactive,
534 });
535 }
536 }
537
538 if self.adjusted_value_start < self.slider_end - self.end_gap - outer {
540 let start = self.adjusted_value_start + self.end_gap;
541 let end = self.slider_end;
542 if end > start {
543 out.push(TrackSegment {
544 start,
545 end,
546 start_corner: if rtl { outer } else { inner },
547 end_corner: if rtl { inner } else { outer },
548 role: SegmentRole::Inactive,
549 });
550 }
551 }
552
553 let start_corner = if rtl || self.centered || self.range {
555 inner
556 } else {
557 outer
558 };
559 let end_corner = if rtl && !self.centered && !self.range {
560 outer
561 } else {
562 inner
563 };
564 if self.active_end - self.active_start > start_corner {
565 out.push(TrackSegment {
566 start: self.active_start,
567 end: self.active_end,
568 start_corner,
569 end_corner,
570 role: SegmentRole::Active,
571 });
572 }
573 out
574 }
575}
576
577#[derive(Clone, Copy, Debug, PartialEq)]
581pub(crate) struct DotPlacement {
582 pub(crate) primary: f64,
583 pub(crate) size: f64,
584 pub(crate) active: bool,
585}
586
587#[derive(Clone, Copy, Debug, PartialEq)]
589pub(crate) struct DotSpec {
590 pub(crate) stop_size: f64,
591 pub(crate) tick_size: f64,
592 pub(crate) edge_inset: f64,
594}
595
596pub(crate) fn dots(metrics: &TrackMetrics, ticks: &[f64], spec: &DotSpec) -> Vec<DotPlacement> {
600 let mut out = Vec::new();
601 let stop_radius = spec.stop_size / 2.0;
604 let stop_start = metrics.slider_start + spec.edge_inset + stop_radius;
605 let stop_end = metrics.slider_end - spec.edge_inset - stop_radius;
606 if stop_end <= stop_start {
607 return out;
608 }
609 for primary in [stop_start, stop_end] {
610 if !on_active_or_gap(primary, metrics) {
611 out.push(DotPlacement {
612 primary,
613 size: spec.stop_size,
614 active: false,
615 });
616 }
617 }
618
619 if ticks.is_empty() {
620 return out;
621 }
622 let (tick_start, tick_end) = (stop_start, stop_end);
624 let center_gap_lo = metrics.center_axis - metrics.end_gap;
625 let center_gap_hi = metrics.center_axis + metrics.end_gap;
626 let start_gap_lo = metrics.value_start - metrics.start_gap;
627 let start_gap_hi = metrics.value_start + metrics.start_gap;
628 let end_gap_lo = metrics.value_end - metrics.end_gap;
629 let end_gap_hi = metrics.value_end + metrics.end_gap;
630 for (i, fraction) in ticks.iter().enumerate() {
631 if i == 0 || i == ticks.len() - 1 {
633 continue;
634 }
635 let center = tick_start + (tick_end - tick_start) * fraction;
636 if metrics.centered && center >= center_gap_lo && center <= center_gap_hi {
637 continue;
638 }
639 if metrics.range && center >= start_gap_lo && center <= start_gap_hi {
641 continue;
642 }
643 if center >= end_gap_lo && center <= end_gap_hi {
644 continue;
645 }
646 out.push(DotPlacement {
647 primary: center,
648 size: spec.tick_size,
649 active: center >= metrics.active_start && center <= metrics.active_end,
650 });
651 }
652 out
653}
654
655fn on_active_or_gap(primary: f64, metrics: &TrackMetrics) -> bool {
658 if primary >= metrics.active_start && primary <= metrics.active_end {
659 return true;
660 }
661 if metrics.range
662 && primary >= metrics.value_start - metrics.start_gap
663 && primary <= metrics.value_start + metrics.start_gap
664 {
665 return true;
666 }
667 if primary >= metrics.value_end - metrics.end_gap
668 && primary <= metrics.value_end + metrics.end_gap
669 {
670 return true;
671 }
672 metrics.centered
673 && metrics.start_gap > 0.0
674 && primary >= metrics.value_end - metrics.start_gap
675 && primary <= metrics.value_end + metrics.start_gap
676}
677
678pub(crate) fn thumb_rect(primary: f64, cross_center: f64, thickness: f64, length: f64) -> Rect {
682 Rect::from_center_size(
683 Point::new(primary, cross_center),
684 Size::new(thickness, length),
685 )
686}
687
688pub(crate) fn thumb_radius(thumb: Rect) -> f64 {
691 thumb.width().max(thumb.height()) / 2.0
692}
693
694pub(crate) fn focus_ring_rect(thumb: Rect) -> Rect {
698 thumb.inflate(FOCUS_INFLATE / 2.0, FOCUS_INFLATE / 2.0)
699}
700
701pub(crate) fn value_indicator_origin(primary: f64) -> Point {
706 Point::new(
707 primary - VALUE_INDICATOR_LEAD,
708 -VALUE_INDICATOR_BOTTOM_SPACE - VALUE_INDICATOR_LIFT,
709 )
710}
711
712#[derive(Clone, Copy, Debug, PartialEq)]
715pub(crate) struct IconDock {
716 pub(crate) resting_center: f64,
718 pub(crate) docked_target: f64,
720 pub(crate) docked: bool,
722 min_center: f64,
723 max_center: f64,
724}
725
726#[derive(Clone, Copy, Debug, PartialEq)]
728pub(crate) struct IconDockInput {
729 pub(crate) extent: f64,
730 pub(crate) thumb_primary: f64,
731 pub(crate) icon_size: f64,
732 pub(crate) edge_inset: f64,
733 pub(crate) handle_thickness: f64,
734 pub(crate) position: SliderIconPosition,
735 pub(crate) reverse: bool,
736}
737
738pub(crate) fn icon_dock(input: &IconDockInput) -> IconDock {
741 let icon_half = input.icon_size / 2.0;
742 let thumb_half = input.handle_thickness / 2.0;
743 let near_end = (input.position == SliderIconPosition::End) != input.reverse;
744 let resting_center = if near_end {
745 input.extent - input.edge_inset - icon_half
746 } else {
747 input.edge_inset + icon_half
748 };
749 let dock_distance_limit = thumb_half + icon_half + ICON_DOCK_SLACK;
750 let docked = (input.thumb_primary - resting_center).abs() <= dock_distance_limit;
751 let dock_offset = thumb_half + icon_half + ICON_DOCK_OFFSET_SLACK;
752 let docked_target = if input.reverse {
753 input.thumb_primary + dock_offset
754 } else {
755 input.thumb_primary - dock_offset
756 };
757 IconDock {
766 resting_center,
767 docked_target,
768 docked,
769 min_center: icon_half.min(input.extent - icon_half),
770 max_center: icon_half.max(input.extent - icon_half),
771 }
772}
773
774impl IconDock {
775 pub(crate) fn center_at(&self, t: f64) -> f64 {
778 let raw = self.resting_center + (self.docked_target - self.resting_center) * t;
779 raw.clamp(self.min_center, self.max_center)
780 }
781
782 pub(crate) fn over_active(&self, center: f64, thumb_primary: f64, reverse: bool) -> bool {
785 if reverse {
786 center >= thumb_primary
787 } else {
788 center <= thumb_primary
789 }
790 }
791}
792
793#[derive(Clone, Copy, Debug, PartialEq, Eq)]
797pub(crate) enum TrackIconSlot {
798 ActiveStart,
800 ActiveEnd,
802 InactiveStart,
804 InactiveEnd,
806}
807
808impl TrackIconSlot {
809 pub(crate) const fn index(self) -> usize {
812 match self {
813 Self::ActiveStart => 0,
814 Self::ActiveEnd => 1,
815 Self::InactiveStart => 2,
816 Self::InactiveEnd => 3,
817 }
818 }
819}
820
821#[derive(Clone, Copy, Debug, PartialEq)]
824pub(crate) struct TrackIconPlacement {
825 pub(crate) slot: TrackIconSlot,
826 pub(crate) primary: f64,
827 pub(crate) active: bool,
828}
829
830pub(crate) fn track_icon_placements(
838 extent: f64,
839 fraction: f64,
840 kind: SliderTrackKind,
841 icon_size: f64,
842 present: [bool; 4],
843) -> Vec<TrackIconPlacement> {
844 let active_len = fraction * extent;
845 let inactive_len = (1.0 - fraction) * extent;
846 let candidates: Vec<(TrackIconSlot, f64, bool)> = match kind {
847 SliderTrackKind::Range => Vec::new(),
851 SliderTrackKind::Centered => vec![
852 (
853 TrackIconSlot::ActiveStart,
854 extent / 2.0 - icon_size - TRACK_ICON_INSET,
855 true,
856 ),
857 (
858 TrackIconSlot::ActiveEnd,
859 active_len + TRACK_ICON_INSET,
860 true,
861 ),
862 ],
863 SliderTrackKind::Standard => vec![
864 (TrackIconSlot::ActiveStart, TRACK_ICON_INSET, true),
865 (
866 TrackIconSlot::ActiveEnd,
867 active_len - icon_size - TRACK_ICON_INSET,
868 true,
869 ),
870 (
871 TrackIconSlot::InactiveStart,
872 active_len + TRACK_ICON_INSET,
873 false,
874 ),
875 (
876 TrackIconSlot::InactiveEnd,
877 extent - icon_size - TRACK_ICON_INSET,
878 false,
879 ),
880 ],
881 };
882
883 candidates
884 .into_iter()
885 .filter(|(slot, _, _)| present[slot.index()])
886 .filter(|(_, _, active)| {
887 let len = if *active { active_len } else { inactive_len };
888 len >= icon_size + TRACK_ICON_MIN_SLACK
889 })
890 .map(|(slot, primary, active)| TrackIconPlacement {
891 slot,
892 primary,
893 active,
894 })
895 .collect()
896}
897
898#[derive(Clone, Copy, Debug)]
908pub(crate) struct DragCtx {
909 pub(crate) spec: ValueSpec,
910 pub(crate) current: f64,
913 pub(crate) extent: f64,
914 pub(crate) reverse: bool,
915}
916
917#[derive(Clone, Copy, Debug, Default, PartialEq)]
919pub(crate) struct DragOutcome {
920 pub(crate) handled: bool,
922 pub(crate) capture: bool,
924 pub(crate) value: Option<f64>,
926 pub(crate) end: bool,
928}
929
930#[derive(Clone, Copy, Debug, Default, PartialEq)]
934pub(crate) struct Drag {
935 pub(crate) pressed: bool,
937 pub(crate) dragging: bool,
939}
940
941impl Drag {
942 pub(crate) fn down(&mut self, primary: f64, ctx: &DragCtx) -> DragOutcome {
945 self.pressed = true;
946 self.dragging = true;
947 DragOutcome {
948 handled: true,
949 capture: true,
950 value: self.resolve(primary, ctx),
951 end: false,
952 }
953 }
954
955 pub(crate) fn moved(&mut self, primary: f64, ctx: &DragCtx) -> DragOutcome {
958 if !self.dragging {
959 return DragOutcome::default();
960 }
961 DragOutcome {
962 handled: true,
963 capture: false,
964 value: self.resolve(primary, ctx),
965 end: false,
966 }
967 }
968
969 pub(crate) fn up(&mut self) -> DragOutcome {
971 if !self.dragging {
972 return DragOutcome::default();
973 }
974 self.pressed = false;
975 self.dragging = false;
976 DragOutcome {
977 handled: true,
978 capture: false,
979 value: None,
980 end: true,
981 }
982 }
983
984 pub(crate) fn cancel(&mut self) -> DragOutcome {
994 if !self.dragging {
995 return DragOutcome::default();
996 }
997 self.pressed = false;
998 self.dragging = false;
999 DragOutcome {
1000 handled: true,
1001 capture: false,
1002 value: None,
1003 end: false,
1004 }
1005 }
1006
1007 fn resolve(&self, primary: f64, ctx: &DragCtx) -> Option<f64> {
1008 let raw = ctx.spec.value_from_offset(primary, ctx.extent, ctx.reverse);
1009 accept_value(&ctx.spec, ctx.current, raw)
1010 }
1011}
1012
1013#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1019pub(crate) enum HapticCadence {
1020 Immediate,
1023 Throttled,
1026}
1027
1028#[derive(Clone, Copy, Debug, Default, PartialEq)]
1050pub(crate) struct HapticScheduler {
1051 last_fire: Option<FrameTime>,
1052 pending: Option<(HapticSignal, HapticCadence)>,
1053}
1054
1055impl HapticScheduler {
1056 pub(crate) fn queue(&mut self, signal: HapticSignal, cadence: HapticCadence) {
1060 self.pending = Some((signal, cadence));
1061 }
1062
1063 #[cfg(test)]
1067 pub(crate) fn has_pending(&self) -> bool {
1068 self.pending.is_some()
1069 }
1070
1071 pub(crate) fn take(&mut self, now: FrameTime) -> Option<HapticSignal> {
1074 let (signal, cadence) = self.pending.take()?;
1075 let eligible = match cadence {
1076 HapticCadence::Immediate => true,
1077 HapticCadence::Throttled => match self.last_fire {
1078 None => true,
1079 Some(last) => now.saturating_sub(last) >= HAPTIC_MIN_INTERVAL,
1080 },
1081 };
1082 if !eligible {
1083 return None;
1084 }
1085 self.last_fire = Some(now);
1086 Some(signal)
1087 }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093
1094 const SPEC_UNIT: ValueSpec = ValueSpec {
1095 min: 0.0,
1096 max: 1.0,
1097 divisions: None,
1098 };
1099
1100 fn spec(min: f64, max: f64, divisions: Option<u32>) -> ValueSpec {
1101 ValueSpec {
1102 min,
1103 max,
1104 divisions,
1105 }
1106 }
1107
1108 fn geometry(fraction: f64, kind: SliderTrackKind) -> SliderGeometry {
1109 SliderGeometry {
1110 extent: 200.0,
1111 active_start_fraction: 0.0,
1112 active_end_fraction: fraction,
1113 reverse: false,
1114 kind,
1115 handle_thickness: HANDLE_WIDTH,
1116 handle_gap: HANDLE_GAP,
1117 track_thickness: TRACK_HEIGHT,
1118 corner_radius: TRACK_CORNER_RADIUS,
1119 }
1120 }
1121
1122 #[test]
1125 fn fraction_maps_bounds_and_clamps_outside_them() {
1126 let s = spec(10.0, 20.0, None);
1127 assert_eq!(s.fraction(10.0), 0.0, "min edge");
1128 assert_eq!(s.fraction(15.0), 0.5);
1129 assert_eq!(s.fraction(20.0), 1.0, "max edge");
1130 assert_eq!(s.fraction(-5.0), 0.0, "below min clamps");
1131 assert_eq!(s.fraction(999.0), 1.0, "above max clamps");
1132 assert_eq!(spec(5.0, 5.0, None).fraction(5.0), 0.0);
1134 }
1135
1136 #[test]
1137 fn value_from_fraction_round_trips_through_fraction() {
1138 let s = spec(-40.0, 60.0, None);
1139 for f in [0.0, 0.25, 0.5, 0.75, 1.0] {
1140 let v = s.value_from_fraction(f);
1141 assert!((s.fraction(v) - f).abs() < 1e-12, "fraction {f} round trip");
1142 }
1143 assert_eq!(s.value_from_fraction(0.0), -40.0);
1144 assert_eq!(s.value_from_fraction(1.0), 60.0);
1145 }
1146
1147 #[test]
1148 fn discrete_snapping_lands_on_steps_including_both_edges() {
1149 let s = spec(0.0, 10.0, Some(5)); assert_eq!(s.snap(0.0), 0.0, "min edge stays put");
1151 assert_eq!(s.snap(10.0), 10.0, "max edge stays put");
1152 assert_eq!(s.snap(0.9), 0.0, "rounds down");
1153 assert_eq!(s.snap(1.1), 2.0, "rounds up");
1154 assert_eq!(s.snap(-3.0), 0.0, "clamps below min before snapping");
1155 assert_eq!(s.snap(99.0), 10.0, "clamps above max before snapping");
1156 assert_eq!(spec(0.0, 10.0, None).snap(3.3), 3.3);
1158 assert_eq!(spec(0.0, 10.0, Some(0)).snap(3.3), 3.3);
1160 }
1161
1162 #[test]
1163 fn snap_degrades_to_the_minimum_instead_of_panicking_on_a_bad_bound() {
1164 let inverted = spec(20.0, 10.0, None);
1167 assert_eq!(inverted.snap(15.0), 20.0, "pins at min, never panics");
1168 assert_eq!(inverted.snap(-5.0), 20.0);
1169
1170 assert_eq!(spec(5.0, 5.0, Some(4)).snap(5.0), 5.0);
1174
1175 assert_eq!(spec(0.0, f64::NAN, None).snap(3.0), 0.0, "NaN max");
1180 assert_eq!(
1181 spec(f64::NAN, 10.0, None).snap(3.0),
1182 0.0,
1183 "NaN min pins to 0.0 instead of propagating"
1184 );
1185
1186 assert_eq!(
1188 spec(20.0, 30.0, None).snap(f64::NAN),
1189 20.0,
1190 "NaN value pins to min"
1191 );
1192
1193 assert_eq!(spec(0.0, 10.0, None).snap(4.0), 4.0);
1195 }
1196
1197 #[test]
1198 fn tick_fractions_are_divisions_plus_one_marks_endpoints_included() {
1199 assert_eq!(
1200 spec(0.0, 1.0, Some(4)).tick_fractions(),
1201 vec![0.0, 0.25, 0.5, 0.75, 1.0]
1202 );
1203 assert!(SPEC_UNIT.tick_fractions().is_empty(), "continuous has none");
1204 assert!(spec(0.0, 1.0, Some(0)).tick_fractions().is_empty());
1205 }
1206
1207 #[test]
1208 fn value_from_offset_maps_position_snaps_and_honours_reverse() {
1209 let s = spec(0.0, 100.0, None);
1210 assert_eq!(s.value_from_offset(0.0, 200.0, false), 0.0);
1211 assert_eq!(s.value_from_offset(100.0, 200.0, false), 50.0);
1212 assert_eq!(s.value_from_offset(200.0, 200.0, false), 100.0);
1213 assert_eq!(s.value_from_offset(-40.0, 200.0, false), 0.0, "clamps");
1214 assert_eq!(s.value_from_offset(9000.0, 200.0, false), 100.0, "clamps");
1215 assert_eq!(s.value_from_offset(10.0, 0.0, false), 0.0, "zero extent");
1216 assert_eq!(s.value_from_offset(0.0, 200.0, true), 100.0);
1218 assert_eq!(s.value_from_offset(150.0, 200.0, true), 25.0);
1219 let d = spec(0.0, 10.0, Some(10));
1221 assert_eq!(d.value_from_offset(37.0, 200.0, false), 2.0);
1222 }
1223
1224 #[test]
1225 fn accept_value_drops_a_change_that_lands_on_the_held_value() {
1226 let s = spec(0.0, 10.0, Some(10));
1227 assert_eq!(accept_value(&s, 3.0, 3.2), None, "snaps back onto 3");
1228 assert_eq!(accept_value(&s, 3.0, 3.6), Some(4.0));
1229 assert_eq!(accept_value(&SPEC_UNIT, 0.5, 0.5), None);
1230 assert_eq!(accept_value(&SPEC_UNIT, 0.5, 0.6), Some(0.6));
1231 assert_eq!(accept_value(&SPEC_UNIT, 0.5, 9.0), Some(1.0), "clamped");
1232 }
1233
1234 #[test]
1237 fn a_zero_length_track_resolves_no_metrics() {
1238 let mut g = geometry(0.5, SliderTrackKind::Standard);
1239 g.extent = 0.0;
1240 assert!(TrackMetrics::resolve(&g).is_none());
1241 }
1242
1243 #[test]
1244 fn a_standard_track_paints_the_inactive_remainder_then_the_active_span() {
1245 let g = geometry(0.5, SliderTrackKind::Standard);
1246 let m = TrackMetrics::resolve(&g).expect("metrics");
1247 let segments = m.segments(false);
1248 assert_eq!(segments.len(), 2, "inactive trailing + active");
1249
1250 let inactive = segments[0];
1251 let active = segments[1];
1252 assert_eq!(active.role, SegmentRole::Active);
1253 assert_eq!(inactive.role, SegmentRole::Inactive);
1254
1255 assert_eq!(active.start, 0.0);
1258 assert_eq!(active.end, 92.0);
1259 assert_eq!(inactive.start, 108.0);
1260 assert_eq!(inactive.end, 200.0);
1261 assert_eq!(
1262 inactive.start - active.end,
1263 2.0 * (HANDLE_WIDTH / 2.0 + HANDLE_GAP)
1264 );
1265
1266 assert_eq!(active.start_corner, TRACK_CORNER_RADIUS);
1268 assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
1269 assert_eq!(inactive.start_corner, TRACK_INSIDE_CORNER);
1270 assert_eq!(inactive.end_corner, TRACK_CORNER_RADIUS);
1271 }
1272
1273 #[test]
1274 fn the_outer_corner_clamps_to_half_the_track_thickness() {
1275 let mut g = geometry(0.5, SliderTrackKind::Standard);
1276 g.track_thickness = 6.0;
1277 let m = TrackMetrics::resolve(&g).expect("metrics");
1278 assert_eq!(m.corner, 3.0, "clamped from the 8dp token");
1279 }
1280
1281 #[test]
1282 fn a_thumb_at_either_end_drops_the_segment_it_would_swallow() {
1283 let m = TrackMetrics::resolve(&geometry(0.0, SliderTrackKind::Standard)).expect("metrics");
1286 let segments = m.segments(false);
1287 assert_eq!(segments.len(), 1);
1288 assert_eq!(segments[0].role, SegmentRole::Inactive);
1289
1290 let m = TrackMetrics::resolve(&geometry(1.0, SliderTrackKind::Standard)).expect("metrics");
1292 let segments = m.segments(false);
1293 assert_eq!(segments.len(), 1);
1294 assert_eq!(segments[0].role, SegmentRole::Active);
1295 }
1296
1297 #[test]
1298 fn a_centered_track_grows_from_the_midpoint_and_keeps_both_inactive_ends() {
1299 let g = geometry(0.75, SliderTrackKind::Centered);
1300 let m = TrackMetrics::resolve(&g).expect("metrics");
1301 assert_eq!(m.center_axis, 100.0);
1302 let segments = m.segments(false);
1303 assert_eq!(
1304 segments.len(),
1305 3,
1306 "inactive leading, inactive trailing, active"
1307 );
1308 assert_eq!(segments[0].role, SegmentRole::Inactive);
1309 assert_eq!(segments[1].role, SegmentRole::Inactive);
1310 assert_eq!(segments[2].role, SegmentRole::Active);
1311
1312 let active = segments[2];
1319 assert_eq!(active.start, 100.0, "the midpoint itself");
1320 assert_eq!(active.end, 142.0, "thumb at 150 less the 8dp gap");
1321 assert_eq!(active.start_corner, TRACK_INSIDE_CORNER);
1322 assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
1323 assert_eq!(segments[0].start, 0.0);
1324 assert_eq!(segments[0].end, 92.0, "stops short of the midpoint gap");
1325 assert_eq!(segments[1].start, 158.0, "thumb at 150 plus the gap");
1326 assert_eq!(segments[1].end, 200.0);
1327 }
1328
1329 #[test]
1330 fn a_centered_track_below_the_midpoint_mirrors_the_active_span() {
1331 let m = TrackMetrics::resolve(&geometry(0.25, SliderTrackKind::Centered)).expect("metrics");
1332 let segments = m.segments(false);
1333 let active = segments
1334 .iter()
1335 .find(|s| s.role == SegmentRole::Active)
1336 .expect("an active span left of centre");
1337 assert_eq!(active.start, 58.0, "thumb at 50 plus the gap");
1338 assert_eq!(active.end, 100.0, "the midpoint itself");
1339 }
1340
1341 fn range_geometry(low: f64, high: f64) -> SliderGeometry {
1343 SliderGeometry {
1344 active_start_fraction: low,
1345 active_end_fraction: high,
1346 ..geometry(high, SliderTrackKind::Range)
1347 }
1348 }
1349
1350 #[test]
1351 fn a_range_track_spans_between_two_thumbs_and_keeps_both_inactive_ends() {
1352 let m = TrackMetrics::resolve(&range_geometry(0.25, 0.75)).expect("metrics");
1353 let segments = m.segments(false);
1354 assert_eq!(
1355 segments.len(),
1356 3,
1357 "inactive leading, inactive trailing, active"
1358 );
1359 let active = segments[2];
1361 assert_eq!(active.role, SegmentRole::Active);
1362 assert_eq!(active.start, 58.0, "low thumb at 50 plus its gap");
1363 assert_eq!(active.end, 142.0, "high thumb at 150 less its gap");
1364 assert_eq!(
1365 active.start_corner, TRACK_INSIDE_CORNER,
1366 "both ends face a gap"
1367 );
1368 assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
1369 assert_eq!((segments[0].start, segments[0].end), (0.0, 42.0));
1370 assert_eq!((segments[1].start, segments[1].end), (158.0, 200.0));
1371 }
1372
1373 #[test]
1374 fn a_range_tracks_dots_clear_both_thumb_gaps() {
1375 let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
1376 let m = TrackMetrics::resolve(&range_geometry(0.25, 0.75)).expect("metrics");
1377 let placed = dots(&m, &ticks, &dot_spec());
1378 let primaries: Vec<f64> = placed.iter().map(|d| d.primary).collect();
1381 assert_eq!(primaries, vec![8.0, 192.0, 100.0], "{placed:?}");
1382 assert!(
1383 placed[2].active,
1384 "the surviving tick lies on the active span"
1385 );
1386 }
1387
1388 #[test]
1389 fn a_range_track_hosts_no_inset_icons() {
1390 assert!(
1391 track_icon_placements(
1392 200.0,
1393 0.5,
1394 SliderTrackKind::Range,
1395 TRACK_ICON_SIZE,
1396 [true; 4]
1397 )
1398 .is_empty()
1399 );
1400 }
1401
1402 #[test]
1403 fn rtl_mirrors_which_end_of_each_segment_takes_the_outer_radius() {
1404 let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
1405 let ltr = m.segments(false);
1406 let rtl = m.segments(true);
1407 assert_eq!(ltr.len(), rtl.len());
1408 for (l, r) in ltr.iter().zip(rtl.iter()) {
1409 assert_eq!((l.start, l.end), (r.start, r.end), "spans are unmirrored");
1410 assert_eq!(l.start_corner, r.end_corner);
1411 assert_eq!(l.end_corner, r.start_corner);
1412 }
1413 }
1414
1415 #[test]
1416 fn a_pressed_handle_narrows_the_gap_instantly() {
1417 let mut g = geometry(0.5, SliderTrackKind::Standard);
1418 g.handle_thickness = PRESSED_HANDLE_WIDTH;
1419 let m = TrackMetrics::resolve(&g).expect("metrics");
1420 assert_eq!(m.end_gap, PRESSED_HANDLE_WIDTH / 2.0 + HANDLE_GAP);
1421 assert_eq!(m.segments(false)[1].end, 93.0, "active reaches 1dp further");
1422 }
1423
1424 fn dot_spec() -> DotSpec {
1427 DotSpec {
1428 stop_size: STOP_INDICATOR_SIZE,
1429 tick_size: TICK_SIZE,
1430 edge_inset: STOP_INDICATOR_TRAILING_SPACE,
1431 }
1432 }
1433
1434 #[test]
1435 fn a_continuous_track_keeps_only_the_stop_the_active_span_does_not_cover() {
1436 let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
1437 let placed = dots(&m, &[], &dot_spec());
1438 assert_eq!(placed.len(), 1, "the leading stop is under the active span");
1439 assert_eq!(placed[0].primary, 192.0);
1441 assert_eq!(placed[0].size, STOP_INDICATOR_SIZE);
1442 assert!(!placed[0].active, "an end stop always takes the stop role");
1443 }
1444
1445 #[test]
1446 fn a_thumb_at_the_maximum_leaves_no_stop_at_all() {
1447 let m = TrackMetrics::resolve(&geometry(1.0, SliderTrackKind::Standard)).expect("metrics");
1448 assert!(
1449 dots(&m, &[], &dot_spec()).is_empty(),
1450 "both ends sit on the active span or in its gap"
1451 );
1452 }
1453
1454 #[test]
1455 fn discrete_ticks_skip_the_endpoints_and_the_handle_gap() {
1456 let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
1457 let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
1458 let placed = dots(&m, &ticks, &dot_spec());
1459 assert_eq!(placed.len(), 3, "{placed:?}");
1463 assert_eq!(placed[0].primary, 192.0, "the surviving end stop, first");
1464 assert_eq!(placed[1].primary, 54.0, "tick at 0.25");
1465 assert_eq!(placed[2].primary, 146.0, "tick at 0.75");
1466 assert!(placed[1].active, "0.25 lies on the active span");
1467 assert!(!placed[2].active, "0.75 lies past the thumb");
1468 }
1469
1470 #[test]
1471 fn a_longer_active_span_flips_more_ticks_to_the_active_role() {
1472 let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
1473 let m = TrackMetrics::resolve(&geometry(0.9, SliderTrackKind::Standard)).expect("metrics");
1474 let placed = dots(&m, &ticks, &dot_spec());
1475 assert_eq!(
1476 placed.iter().filter(|d| d.active).count(),
1477 3,
1478 "every interior tick is inside a 90%-long active span: {placed:?}"
1479 );
1480 }
1481
1482 #[test]
1485 fn the_thumb_box_is_centred_on_its_value_and_pill_rounded() {
1486 let thumb = thumb_rect(100.0, 22.0, HANDLE_WIDTH, HANDLE_HEIGHT);
1487 assert_eq!(thumb.x0, 98.0);
1488 assert_eq!(thumb.x1, 102.0);
1489 assert_eq!(thumb.y0, 0.0);
1490 assert_eq!(thumb.y1, 44.0);
1491 assert_eq!(thumb_radius(thumb), HANDLE_HEIGHT / 2.0);
1492
1493 let ring = focus_ring_rect(thumb);
1494 assert_eq!(ring.width(), HANDLE_WIDTH + FOCUS_INFLATE);
1495 assert_eq!(ring.height(), HANDLE_HEIGHT + FOCUS_INFLATE);
1496 }
1497
1498 #[test]
1499 fn the_value_indicator_floats_above_the_control_box() {
1500 let origin = value_indicator_origin(100.0);
1501 assert_eq!(origin.x, 76.0);
1502 assert_eq!(
1503 origin.y,
1504 -(VALUE_INDICATOR_BOTTOM_SPACE + VALUE_INDICATOR_LIFT)
1505 );
1506 assert!(origin.y < 0.0, "it overflows the control's own box");
1507 }
1508
1509 fn dock_input(thumb_primary: f64) -> IconDockInput {
1510 IconDockInput {
1511 extent: 200.0,
1512 thumb_primary,
1513 icon_size: ICON_SIZE_DEFAULT,
1514 edge_inset: ICON_EDGE_INSET,
1515 handle_thickness: HANDLE_WIDTH,
1516 position: SliderIconPosition::End,
1517 reverse: false,
1518 }
1519 }
1520
1521 #[test]
1522 fn the_end_icon_rests_at_the_trailing_edge_until_the_thumb_nears_it() {
1523 let far = icon_dock(&dock_input(20.0));
1524 assert_eq!(far.resting_center, 180.0, "extent - inset - half the icon");
1525 assert!(!far.docked);
1526 assert_eq!(far.center_at(0.0), 180.0, "resting while undocked");
1527
1528 let near = icon_dock(&dock_input(160.0));
1530 assert!(near.docked, "within the dock distance limit");
1531 assert_eq!(near.docked_target, 160.0 - (2.0 + 12.0 + 12.0));
1532 assert_eq!(near.center_at(1.0), 134.0);
1533 }
1534
1535 #[test]
1536 fn a_docked_icon_stays_inside_the_track_and_reports_which_segment_it_is_over() {
1537 let dock = icon_dock(&dock_input(4.0));
1538 assert_eq!(dock.center_at(1.0), 12.0, "clamped to half the icon");
1539 assert!(
1540 !dock.over_active(180.0, 4.0, false),
1541 "resting past the thumb is over the inactive segment"
1542 );
1543 assert!(dock.over_active(2.0, 4.0, false));
1544 }
1545
1546 #[test]
1547 fn a_track_narrower_than_its_icon_docks_without_panicking() {
1548 let mut input = dock_input(4.0);
1553 input.extent = 10.0;
1554 let dock = icon_dock(&input);
1555 assert_eq!(
1556 dock.min_center, -2.0,
1557 "ordered: extent - icon_half < icon_half"
1558 );
1559 assert_eq!(dock.max_center, 12.0);
1560 assert_eq!(dock.center_at(0.0), dock.min_center);
1564 assert_eq!(dock.center_at(1.0), dock.min_center);
1565 assert!(dock.center_at(0.5).is_finite());
1566 }
1567
1568 #[test]
1569 fn a_start_positioned_icon_rests_at_the_leading_edge() {
1570 let mut input = dock_input(180.0);
1571 input.position = SliderIconPosition::Start;
1572 assert_eq!(icon_dock(&input).resting_center, 20.0);
1573 }
1574
1575 #[test]
1578 fn inset_track_icons_place_per_segment_and_drop_out_of_a_short_one() {
1579 let all = [true; 4];
1580 let placed =
1581 track_icon_placements(200.0, 0.5, SliderTrackKind::Standard, TRACK_ICON_SIZE, all);
1582 assert_eq!(placed.len(), 4);
1583 assert_eq!(placed[0].primary, TRACK_ICON_INSET);
1584 assert_eq!(
1585 placed[1].primary,
1586 100.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
1587 );
1588 assert_eq!(placed[2].primary, 104.0);
1589 assert_eq!(
1590 placed[3].primary,
1591 200.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
1592 );
1593 assert!(placed[0].active && placed[1].active);
1594 assert!(!placed[2].active && !placed[3].active);
1595
1596 let squeezed =
1598 track_icon_placements(200.0, 0.05, SliderTrackKind::Standard, TRACK_ICON_SIZE, all);
1599 assert!(squeezed.iter().all(|p| !p.active), "{squeezed:?}");
1600 }
1601
1602 #[test]
1603 fn a_slot_with_no_icon_is_never_placed() {
1604 let only_active_start = [true, false, false, false];
1605 let placed = track_icon_placements(
1606 200.0,
1607 0.5,
1608 SliderTrackKind::Standard,
1609 TRACK_ICON_SIZE,
1610 only_active_start,
1611 );
1612 assert_eq!(placed.len(), 1);
1613 assert_eq!(placed[0].slot, TrackIconSlot::ActiveStart);
1614 }
1615
1616 #[test]
1617 fn a_centered_track_hosts_active_slot_icons_only() {
1618 let placed = track_icon_placements(
1619 200.0,
1620 0.75,
1621 SliderTrackKind::Centered,
1622 TRACK_ICON_SIZE,
1623 [true; 4],
1624 );
1625 assert_eq!(placed.len(), 2);
1626 assert_eq!(placed[0].slot, TrackIconSlot::ActiveStart);
1627 assert_eq!(
1628 placed[0].primary,
1629 100.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
1630 );
1631 assert_eq!(placed[1].slot, TrackIconSlot::ActiveEnd);
1632 assert_eq!(placed[1].primary, 150.0 + TRACK_ICON_INSET);
1633 }
1634
1635 fn drag_ctx(spec: ValueSpec, current: f64) -> DragCtx {
1638 DragCtx {
1639 spec,
1640 current,
1641 extent: 200.0,
1642 reverse: false,
1643 }
1644 }
1645
1646 #[test]
1647 fn down_move_up_reports_values_then_settles() {
1648 let spec = spec(0.0, 100.0, None);
1649 let mut drag = Drag::default();
1650
1651 let down = drag.down(50.0, &drag_ctx(spec, 0.0));
1652 assert!(down.handled && down.capture);
1653 assert_eq!(down.value, Some(25.0));
1654 assert!(!down.end);
1655 assert!(drag.pressed && drag.dragging);
1656
1657 let moved = drag.moved(150.0, &drag_ctx(spec, 25.0));
1658 assert_eq!(moved.value, Some(75.0));
1659 assert!(!moved.capture, "the capture is already open");
1660
1661 let up = drag.up();
1662 assert!(up.end, "settling owes on_change_end");
1663 assert_eq!(up.value, None, "and reports no new value");
1664 assert!(!drag.pressed && !drag.dragging);
1665 }
1666
1667 #[test]
1668 fn an_uncaptured_move_or_up_is_ignored_outright() {
1669 let spec = ValueSpec::default();
1670 let mut drag = Drag::default();
1671 assert_eq!(
1672 drag.moved(100.0, &drag_ctx(spec, 0.0)),
1673 DragOutcome::default()
1674 );
1675 assert_eq!(drag.up(), DragOutcome::default());
1676 assert_eq!(drag.cancel(), DragOutcome::default());
1677 assert!(!drag.pressed, "a hover move never presses");
1678 }
1679
1680 #[test]
1681 fn cancel_disarms_without_owing_a_change_end() {
1682 let spec = ValueSpec::default();
1683 let mut drag = Drag::default();
1684 drag.down(100.0, &drag_ctx(spec, 0.0));
1685 let cancelled = drag.cancel();
1686 assert!(cancelled.handled);
1687 assert!(!cancelled.end, "a Cancel arm may never reach app state");
1688 assert!(!drag.pressed && !drag.dragging);
1689 }
1690
1691 #[test]
1692 fn a_move_that_lands_on_the_held_value_reports_nothing() {
1693 let spec = spec(0.0, 10.0, Some(10));
1694 let mut drag = Drag::default();
1695 drag.down(100.0, &drag_ctx(spec, 0.0));
1696 assert_eq!(drag.moved(101.0, &drag_ctx(spec, 5.0)).value, None);
1698 assert_eq!(drag.moved(121.0, &drag_ctx(spec, 5.0)).value, Some(6.0));
1699 }
1700
1701 fn ft_ms(ms: u64) -> FrameTime {
1704 FrameTime::from_nanos(ms * 1_000_000)
1705 }
1706
1707 #[test]
1708 fn the_haptic_scheduler_paces_continuous_ticks_and_lets_discrete_ones_through() {
1709 let mut sched = HapticScheduler::default();
1711 assert!(!sched.has_pending());
1712 sched.queue(HapticSignal::SliderTick, HapticCadence::Throttled);
1713 assert!(sched.has_pending());
1714 assert_eq!(sched.take(ft_ms(0)), Some(HapticSignal::SliderTick));
1715 assert!(!sched.has_pending(), "taking drains the queue");
1716 assert_eq!(
1717 sched.take(ft_ms(100)),
1718 None,
1719 "nothing queued, nothing fires"
1720 );
1721
1722 let mut fired = 0;
1725 for frame in 1..=12 {
1726 sched.queue(HapticSignal::SliderTick, HapticCadence::Throttled);
1727 if sched.take(ft_ms(frame * 16)).is_some() {
1728 fired += 1;
1729 }
1730 }
1731 assert_eq!(
1732 fired, 3,
1733 "12 frames * 16ms = 192ms after the first tick: 64/128/192"
1734 );
1735
1736 let mut sched = HapticScheduler::default();
1738 let mut fired = 0;
1739 for frame in 0..12 {
1740 sched.queue(HapticSignal::SliderTick, HapticCadence::Immediate);
1741 if sched.take(ft_ms(frame)).is_some() {
1742 fired += 1;
1743 }
1744 }
1745 assert_eq!(fired, 12, "a per-division tick is never throttled");
1746 }
1747
1748 #[test]
1749 fn a_second_queue_before_the_next_paint_collapses_into_one_tick() {
1750 let mut sched = HapticScheduler::default();
1751 sched.queue(HapticSignal::Light, HapticCadence::Immediate);
1752 sched.queue(HapticSignal::Heavy, HapticCadence::Immediate);
1753 assert_eq!(sched.take(ft_ms(0)), Some(HapticSignal::Heavy));
1754 assert_eq!(sched.take(ft_ms(1)), None);
1755 }
1756}