use std::time::Duration;
use frust::{FrameTime, SpringDesc};
use kurbo::{Point, Rect, Size};
use crate::interaction::HapticSignal;
pub(crate) const HANDLE_HEIGHT: f64 = 44.0;
pub(crate) const HANDLE_WIDTH: f64 = 4.0;
pub(crate) const PRESSED_HANDLE_WIDTH: f64 = 2.0;
pub(crate) const TRACK_HEIGHT: f64 = 16.0;
pub(crate) const HANDLE_GAP: f64 = 6.0;
pub(crate) const TRACK_INSIDE_CORNER: f64 = 2.0;
pub(crate) const TRACK_CORNER_RADIUS: f64 = 8.0;
pub(crate) const STOP_INDICATOR_SIZE: f64 = 4.0;
pub(crate) const TICK_SIZE: f64 = 4.0;
pub(crate) const STOP_INDICATOR_TRAILING_SPACE: f64 = 6.0;
pub(crate) const ICON_EDGE_INSET: f64 = 8.0;
pub(crate) const ICON_SIZE_DEFAULT: f64 = 24.0;
pub(crate) const VALUE_INDICATOR_BOTTOM_SPACE: f64 = 12.0;
pub(crate) const DISABLED_ACTIVE_OPACITY: f32 = 0.38;
pub(crate) const DISABLED_INACTIVE_OPACITY: f32 = 0.12;
pub(crate) const TRACK_ICON_SIZE: f64 = 16.0;
pub(crate) const TRACK_ICON_INSET: f64 = 4.0;
pub(crate) const TRACK_ICON_MIN_SLACK: f64 = 8.0;
pub(crate) const FOCUS_HANDLE_GAP_EXTRA: f64 = 4.0;
pub(crate) const FOCUS_STROKE: f64 = 2.0;
pub(crate) const FOCUS_INFLATE: f64 = 6.0;
pub(crate) const PRESS_MORPH_DURATION: Duration = Duration::from_millis(100);
pub(crate) const HAPTIC_MIN_INTERVAL: Duration = Duration::from_millis(60);
pub(crate) const DOCK_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 800.0,
damping_ratio: 0.6,
};
pub(crate) const ICON_DOCK_SLACK: f64 = 8.0;
pub(crate) const ICON_DOCK_OFFSET_SLACK: f64 = 12.0;
pub(crate) const VALUE_INDICATOR_LEAD: f64 = 24.0;
pub(crate) const VALUE_INDICATOR_LIFT: f64 = 24.0;
pub(crate) const VALUE_INDICATOR_RADIUS: f64 = 4.0;
pub(crate) const VALUE_INDICATOR_PAD_X: f64 = 8.0;
pub(crate) const VALUE_INDICATOR_PAD_Y: f64 = 4.0;
pub(crate) const DEFAULT_EXTENT: f64 = 200.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SliderTrackKind {
#[default]
Standard,
Centered,
Range,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SliderIconPosition {
Start,
#[default]
End,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ValueSpec {
pub(crate) min: f64,
pub(crate) max: f64,
pub(crate) divisions: Option<u32>,
}
impl Default for ValueSpec {
fn default() -> Self {
Self {
min: 0.0,
max: 1.0,
divisions: None,
}
}
}
impl ValueSpec {
pub(crate) fn fraction(&self, value: f64) -> f64 {
if self.max <= self.min {
return 0.0;
}
((value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
}
pub(crate) fn value_from_fraction(&self, fraction: f64) -> f64 {
self.min + fraction.clamp(0.0, 1.0) * (self.max - self.min)
}
pub(crate) fn snap(&self, value: f64) -> f64 {
if !(self.min.is_finite() && self.max.is_finite() && self.max > self.min) {
return if self.min.is_finite() { self.min } else { 0.0 };
}
if value.is_nan() {
return self.min;
}
let clamped = value.clamp(self.min, self.max);
let Some(divisions) = self.divisions.filter(|d| *d > 0) else {
return clamped;
};
let step = (self.max - self.min) / f64::from(divisions);
self.min + ((clamped - self.min) / step).round() * step
}
pub(crate) fn tick_fractions(&self) -> Vec<f64> {
let Some(divisions) = self.divisions.filter(|d| *d > 0) else {
return Vec::new();
};
(0..=divisions)
.map(|i| f64::from(i) / f64::from(divisions))
.collect()
}
pub(crate) fn value_from_offset(&self, primary: f64, extent: f64, reverse: bool) -> f64 {
if extent <= 0.0 {
return self.min;
}
let mut fraction = (primary / extent).clamp(0.0, 1.0);
if reverse {
fraction = 1.0 - fraction;
}
self.snap(self.value_from_fraction(fraction))
}
}
pub(crate) fn accept_value(spec: &ValueSpec, current: f64, raw: f64) -> Option<f64> {
let next = spec.snap(raw);
if next == current { None } else { Some(next) }
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct SliderGeometry {
pub(crate) extent: f64,
pub(crate) active_start_fraction: f64,
pub(crate) active_end_fraction: f64,
pub(crate) reverse: bool,
pub(crate) kind: SliderTrackKind,
pub(crate) handle_thickness: f64,
pub(crate) handle_gap: f64,
pub(crate) track_thickness: f64,
pub(crate) corner_radius: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TrackMetrics {
pub(crate) slider_start: f64,
pub(crate) slider_end: f64,
pub(crate) centered: bool,
pub(crate) range: bool,
pub(crate) corner: f64,
pub(crate) inside_corner: f64,
pub(crate) start_gap: f64,
pub(crate) end_gap: f64,
pub(crate) value_start: f64,
pub(crate) value_end: f64,
pub(crate) center_axis: f64,
pub(crate) adjusted_value_start: f64,
pub(crate) adjusted_value_end: f64,
pub(crate) active_start: f64,
pub(crate) active_end: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SegmentRole {
Active,
Inactive,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TrackSegment {
pub(crate) start: f64,
pub(crate) end: f64,
pub(crate) start_corner: f64,
pub(crate) end_corner: f64,
pub(crate) role: SegmentRole,
}
impl TrackMetrics {
pub(crate) fn resolve(geometry: &SliderGeometry) -> Option<Self> {
let slider_start = 0.0;
let slider_end = geometry.extent;
let span = slider_end - slider_start;
if span <= 0.0 {
return None;
}
let centered = geometry.kind == SliderTrackKind::Centered;
let range = geometry.kind == SliderTrackKind::Range;
let corner = geometry.corner_radius.min(geometry.track_thickness / 2.0);
let start_gap = if centered || range {
geometry.handle_thickness / 2.0 + geometry.handle_gap
} else {
0.0
};
let end_gap = geometry.handle_thickness / 2.0 + geometry.handle_gap;
let value_start = slider_start + span * geometry.active_start_fraction.clamp(0.0, 1.0);
let value_end = slider_start + span * geometry.active_end_fraction.clamp(0.0, 1.0);
let center_axis = (slider_start + slider_end) / 2.0;
let adjusted_value_end = if centered {
value_end.min(center_axis)
} else {
value_start
};
let adjusted_value_start = if centered {
value_end.max(center_axis)
} else {
value_end
};
let active_start = if centered {
adjusted_value_end
+ if adjusted_value_end < center_axis {
start_gap
} else {
0.0
}
} else if range {
value_start + start_gap
} else {
slider_start
};
let active_end = if centered {
adjusted_value_start
- if adjusted_value_start > center_axis {
end_gap
} else {
0.0
}
} else {
value_end - end_gap
};
Some(Self {
slider_start,
slider_end,
centered,
range,
corner,
inside_corner: TRACK_INSIDE_CORNER,
start_gap,
end_gap,
value_start,
value_end,
center_axis,
adjusted_value_start,
adjusted_value_end,
active_start,
active_end,
})
}
pub(crate) fn segments(&self, rtl: bool) -> Vec<TrackSegment> {
let mut out = Vec::with_capacity(3);
let (outer, inner) = (self.corner, self.inside_corner);
if (self.centered || self.range)
&& self.adjusted_value_end > self.slider_start + self.start_gap + outer
{
let start = self.slider_start;
let end = self.adjusted_value_end - self.start_gap;
if end > start {
out.push(TrackSegment {
start,
end,
start_corner: if rtl { inner } else { outer },
end_corner: if rtl { outer } else { inner },
role: SegmentRole::Inactive,
});
}
}
if self.adjusted_value_start < self.slider_end - self.end_gap - outer {
let start = self.adjusted_value_start + self.end_gap;
let end = self.slider_end;
if end > start {
out.push(TrackSegment {
start,
end,
start_corner: if rtl { outer } else { inner },
end_corner: if rtl { inner } else { outer },
role: SegmentRole::Inactive,
});
}
}
let start_corner = if rtl || self.centered || self.range {
inner
} else {
outer
};
let end_corner = if rtl && !self.centered && !self.range {
outer
} else {
inner
};
if self.active_end - self.active_start > start_corner {
out.push(TrackSegment {
start: self.active_start,
end: self.active_end,
start_corner,
end_corner,
role: SegmentRole::Active,
});
}
out
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct DotPlacement {
pub(crate) primary: f64,
pub(crate) size: f64,
pub(crate) active: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct DotSpec {
pub(crate) stop_size: f64,
pub(crate) tick_size: f64,
pub(crate) edge_inset: f64,
}
pub(crate) fn dots(metrics: &TrackMetrics, ticks: &[f64], spec: &DotSpec) -> Vec<DotPlacement> {
let mut out = Vec::new();
let stop_radius = spec.stop_size / 2.0;
let stop_start = metrics.slider_start + spec.edge_inset + stop_radius;
let stop_end = metrics.slider_end - spec.edge_inset - stop_radius;
if stop_end <= stop_start {
return out;
}
for primary in [stop_start, stop_end] {
if !on_active_or_gap(primary, metrics) {
out.push(DotPlacement {
primary,
size: spec.stop_size,
active: false,
});
}
}
if ticks.is_empty() {
return out;
}
let (tick_start, tick_end) = (stop_start, stop_end);
let center_gap_lo = metrics.center_axis - metrics.end_gap;
let center_gap_hi = metrics.center_axis + metrics.end_gap;
let start_gap_lo = metrics.value_start - metrics.start_gap;
let start_gap_hi = metrics.value_start + metrics.start_gap;
let end_gap_lo = metrics.value_end - metrics.end_gap;
let end_gap_hi = metrics.value_end + metrics.end_gap;
for (i, fraction) in ticks.iter().enumerate() {
if i == 0 || i == ticks.len() - 1 {
continue;
}
let center = tick_start + (tick_end - tick_start) * fraction;
if metrics.centered && center >= center_gap_lo && center <= center_gap_hi {
continue;
}
if metrics.range && center >= start_gap_lo && center <= start_gap_hi {
continue;
}
if center >= end_gap_lo && center <= end_gap_hi {
continue;
}
out.push(DotPlacement {
primary: center,
size: spec.tick_size,
active: center >= metrics.active_start && center <= metrics.active_end,
});
}
out
}
fn on_active_or_gap(primary: f64, metrics: &TrackMetrics) -> bool {
if primary >= metrics.active_start && primary <= metrics.active_end {
return true;
}
if metrics.range
&& primary >= metrics.value_start - metrics.start_gap
&& primary <= metrics.value_start + metrics.start_gap
{
return true;
}
if primary >= metrics.value_end - metrics.end_gap
&& primary <= metrics.value_end + metrics.end_gap
{
return true;
}
metrics.centered
&& metrics.start_gap > 0.0
&& primary >= metrics.value_end - metrics.start_gap
&& primary <= metrics.value_end + metrics.start_gap
}
pub(crate) fn thumb_rect(primary: f64, cross_center: f64, thickness: f64, length: f64) -> Rect {
Rect::from_center_size(
Point::new(primary, cross_center),
Size::new(thickness, length),
)
}
pub(crate) fn thumb_radius(thumb: Rect) -> f64 {
thumb.width().max(thumb.height()) / 2.0
}
pub(crate) fn focus_ring_rect(thumb: Rect) -> Rect {
thumb.inflate(FOCUS_INFLATE / 2.0, FOCUS_INFLATE / 2.0)
}
pub(crate) fn value_indicator_origin(primary: f64) -> Point {
Point::new(
primary - VALUE_INDICATOR_LEAD,
-VALUE_INDICATOR_BOTTOM_SPACE - VALUE_INDICATOR_LIFT,
)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct IconDock {
pub(crate) resting_center: f64,
pub(crate) docked_target: f64,
pub(crate) docked: bool,
min_center: f64,
max_center: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct IconDockInput {
pub(crate) extent: f64,
pub(crate) thumb_primary: f64,
pub(crate) icon_size: f64,
pub(crate) edge_inset: f64,
pub(crate) handle_thickness: f64,
pub(crate) position: SliderIconPosition,
pub(crate) reverse: bool,
}
pub(crate) fn icon_dock(input: &IconDockInput) -> IconDock {
let icon_half = input.icon_size / 2.0;
let thumb_half = input.handle_thickness / 2.0;
let near_end = (input.position == SliderIconPosition::End) != input.reverse;
let resting_center = if near_end {
input.extent - input.edge_inset - icon_half
} else {
input.edge_inset + icon_half
};
let dock_distance_limit = thumb_half + icon_half + ICON_DOCK_SLACK;
let docked = (input.thumb_primary - resting_center).abs() <= dock_distance_limit;
let dock_offset = thumb_half + icon_half + ICON_DOCK_OFFSET_SLACK;
let docked_target = if input.reverse {
input.thumb_primary + dock_offset
} else {
input.thumb_primary - dock_offset
};
IconDock {
resting_center,
docked_target,
docked,
min_center: icon_half.min(input.extent - icon_half),
max_center: icon_half.max(input.extent - icon_half),
}
}
impl IconDock {
pub(crate) fn center_at(&self, t: f64) -> f64 {
let raw = self.resting_center + (self.docked_target - self.resting_center) * t;
raw.clamp(self.min_center, self.max_center)
}
pub(crate) fn over_active(&self, center: f64, thumb_primary: f64, reverse: bool) -> bool {
if reverse {
center >= thumb_primary
} else {
center <= thumb_primary
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrackIconSlot {
ActiveStart,
ActiveEnd,
InactiveStart,
InactiveEnd,
}
impl TrackIconSlot {
pub(crate) const fn index(self) -> usize {
match self {
Self::ActiveStart => 0,
Self::ActiveEnd => 1,
Self::InactiveStart => 2,
Self::InactiveEnd => 3,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TrackIconPlacement {
pub(crate) slot: TrackIconSlot,
pub(crate) primary: f64,
pub(crate) active: bool,
}
pub(crate) fn track_icon_placements(
extent: f64,
fraction: f64,
kind: SliderTrackKind,
icon_size: f64,
present: [bool; 4],
) -> Vec<TrackIconPlacement> {
let active_len = fraction * extent;
let inactive_len = (1.0 - fraction) * extent;
let candidates: Vec<(TrackIconSlot, f64, bool)> = match kind {
SliderTrackKind::Range => Vec::new(),
SliderTrackKind::Centered => vec![
(
TrackIconSlot::ActiveStart,
extent / 2.0 - icon_size - TRACK_ICON_INSET,
true,
),
(
TrackIconSlot::ActiveEnd,
active_len + TRACK_ICON_INSET,
true,
),
],
SliderTrackKind::Standard => vec![
(TrackIconSlot::ActiveStart, TRACK_ICON_INSET, true),
(
TrackIconSlot::ActiveEnd,
active_len - icon_size - TRACK_ICON_INSET,
true,
),
(
TrackIconSlot::InactiveStart,
active_len + TRACK_ICON_INSET,
false,
),
(
TrackIconSlot::InactiveEnd,
extent - icon_size - TRACK_ICON_INSET,
false,
),
],
};
candidates
.into_iter()
.filter(|(slot, _, _)| present[slot.index()])
.filter(|(_, _, active)| {
let len = if *active { active_len } else { inactive_len };
len >= icon_size + TRACK_ICON_MIN_SLACK
})
.map(|(slot, primary, active)| TrackIconPlacement {
slot,
primary,
active,
})
.collect()
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct DragCtx {
pub(crate) spec: ValueSpec,
pub(crate) current: f64,
pub(crate) extent: f64,
pub(crate) reverse: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct DragOutcome {
pub(crate) handled: bool,
pub(crate) capture: bool,
pub(crate) value: Option<f64>,
pub(crate) end: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Drag {
pub(crate) pressed: bool,
pub(crate) dragging: bool,
}
impl Drag {
pub(crate) fn down(&mut self, primary: f64, ctx: &DragCtx) -> DragOutcome {
self.pressed = true;
self.dragging = true;
DragOutcome {
handled: true,
capture: true,
value: self.resolve(primary, ctx),
end: false,
}
}
pub(crate) fn moved(&mut self, primary: f64, ctx: &DragCtx) -> DragOutcome {
if !self.dragging {
return DragOutcome::default();
}
DragOutcome {
handled: true,
capture: false,
value: self.resolve(primary, ctx),
end: false,
}
}
pub(crate) fn up(&mut self) -> DragOutcome {
if !self.dragging {
return DragOutcome::default();
}
self.pressed = false;
self.dragging = false;
DragOutcome {
handled: true,
capture: false,
value: None,
end: true,
}
}
pub(crate) fn cancel(&mut self) -> DragOutcome {
if !self.dragging {
return DragOutcome::default();
}
self.pressed = false;
self.dragging = false;
DragOutcome {
handled: true,
capture: false,
value: None,
end: false,
}
}
fn resolve(&self, primary: f64, ctx: &DragCtx) -> Option<f64> {
let raw = ctx.spec.value_from_offset(primary, ctx.extent, ctx.reverse);
accept_value(&ctx.spec, ctx.current, raw)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum HapticCadence {
Immediate,
Throttled,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct HapticScheduler {
last_fire: Option<FrameTime>,
pending: Option<(HapticSignal, HapticCadence)>,
}
impl HapticScheduler {
pub(crate) fn queue(&mut self, signal: HapticSignal, cadence: HapticCadence) {
self.pending = Some((signal, cadence));
}
#[cfg(test)]
pub(crate) fn has_pending(&self) -> bool {
self.pending.is_some()
}
pub(crate) fn take(&mut self, now: FrameTime) -> Option<HapticSignal> {
let (signal, cadence) = self.pending.take()?;
let eligible = match cadence {
HapticCadence::Immediate => true,
HapticCadence::Throttled => match self.last_fire {
None => true,
Some(last) => now.saturating_sub(last) >= HAPTIC_MIN_INTERVAL,
},
};
if !eligible {
return None;
}
self.last_fire = Some(now);
Some(signal)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC_UNIT: ValueSpec = ValueSpec {
min: 0.0,
max: 1.0,
divisions: None,
};
fn spec(min: f64, max: f64, divisions: Option<u32>) -> ValueSpec {
ValueSpec {
min,
max,
divisions,
}
}
fn geometry(fraction: f64, kind: SliderTrackKind) -> SliderGeometry {
SliderGeometry {
extent: 200.0,
active_start_fraction: 0.0,
active_end_fraction: fraction,
reverse: false,
kind,
handle_thickness: HANDLE_WIDTH,
handle_gap: HANDLE_GAP,
track_thickness: TRACK_HEIGHT,
corner_radius: TRACK_CORNER_RADIUS,
}
}
#[test]
fn fraction_maps_bounds_and_clamps_outside_them() {
let s = spec(10.0, 20.0, None);
assert_eq!(s.fraction(10.0), 0.0, "min edge");
assert_eq!(s.fraction(15.0), 0.5);
assert_eq!(s.fraction(20.0), 1.0, "max edge");
assert_eq!(s.fraction(-5.0), 0.0, "below min clamps");
assert_eq!(s.fraction(999.0), 1.0, "above max clamps");
assert_eq!(spec(5.0, 5.0, None).fraction(5.0), 0.0);
}
#[test]
fn value_from_fraction_round_trips_through_fraction() {
let s = spec(-40.0, 60.0, None);
for f in [0.0, 0.25, 0.5, 0.75, 1.0] {
let v = s.value_from_fraction(f);
assert!((s.fraction(v) - f).abs() < 1e-12, "fraction {f} round trip");
}
assert_eq!(s.value_from_fraction(0.0), -40.0);
assert_eq!(s.value_from_fraction(1.0), 60.0);
}
#[test]
fn discrete_snapping_lands_on_steps_including_both_edges() {
let s = spec(0.0, 10.0, Some(5)); assert_eq!(s.snap(0.0), 0.0, "min edge stays put");
assert_eq!(s.snap(10.0), 10.0, "max edge stays put");
assert_eq!(s.snap(0.9), 0.0, "rounds down");
assert_eq!(s.snap(1.1), 2.0, "rounds up");
assert_eq!(s.snap(-3.0), 0.0, "clamps below min before snapping");
assert_eq!(s.snap(99.0), 10.0, "clamps above max before snapping");
assert_eq!(spec(0.0, 10.0, None).snap(3.3), 3.3);
assert_eq!(spec(0.0, 10.0, Some(0)).snap(3.3), 3.3);
}
#[test]
fn snap_degrades_to_the_minimum_instead_of_panicking_on_a_bad_bound() {
let inverted = spec(20.0, 10.0, None);
assert_eq!(inverted.snap(15.0), 20.0, "pins at min, never panics");
assert_eq!(inverted.snap(-5.0), 20.0);
assert_eq!(spec(5.0, 5.0, Some(4)).snap(5.0), 5.0);
assert_eq!(spec(0.0, f64::NAN, None).snap(3.0), 0.0, "NaN max");
assert_eq!(
spec(f64::NAN, 10.0, None).snap(3.0),
0.0,
"NaN min pins to 0.0 instead of propagating"
);
assert_eq!(
spec(20.0, 30.0, None).snap(f64::NAN),
20.0,
"NaN value pins to min"
);
assert_eq!(spec(0.0, 10.0, None).snap(4.0), 4.0);
}
#[test]
fn tick_fractions_are_divisions_plus_one_marks_endpoints_included() {
assert_eq!(
spec(0.0, 1.0, Some(4)).tick_fractions(),
vec![0.0, 0.25, 0.5, 0.75, 1.0]
);
assert!(SPEC_UNIT.tick_fractions().is_empty(), "continuous has none");
assert!(spec(0.0, 1.0, Some(0)).tick_fractions().is_empty());
}
#[test]
fn value_from_offset_maps_position_snaps_and_honours_reverse() {
let s = spec(0.0, 100.0, None);
assert_eq!(s.value_from_offset(0.0, 200.0, false), 0.0);
assert_eq!(s.value_from_offset(100.0, 200.0, false), 50.0);
assert_eq!(s.value_from_offset(200.0, 200.0, false), 100.0);
assert_eq!(s.value_from_offset(-40.0, 200.0, false), 0.0, "clamps");
assert_eq!(s.value_from_offset(9000.0, 200.0, false), 100.0, "clamps");
assert_eq!(s.value_from_offset(10.0, 0.0, false), 0.0, "zero extent");
assert_eq!(s.value_from_offset(0.0, 200.0, true), 100.0);
assert_eq!(s.value_from_offset(150.0, 200.0, true), 25.0);
let d = spec(0.0, 10.0, Some(10));
assert_eq!(d.value_from_offset(37.0, 200.0, false), 2.0);
}
#[test]
fn accept_value_drops_a_change_that_lands_on_the_held_value() {
let s = spec(0.0, 10.0, Some(10));
assert_eq!(accept_value(&s, 3.0, 3.2), None, "snaps back onto 3");
assert_eq!(accept_value(&s, 3.0, 3.6), Some(4.0));
assert_eq!(accept_value(&SPEC_UNIT, 0.5, 0.5), None);
assert_eq!(accept_value(&SPEC_UNIT, 0.5, 0.6), Some(0.6));
assert_eq!(accept_value(&SPEC_UNIT, 0.5, 9.0), Some(1.0), "clamped");
}
#[test]
fn a_zero_length_track_resolves_no_metrics() {
let mut g = geometry(0.5, SliderTrackKind::Standard);
g.extent = 0.0;
assert!(TrackMetrics::resolve(&g).is_none());
}
#[test]
fn a_standard_track_paints_the_inactive_remainder_then_the_active_span() {
let g = geometry(0.5, SliderTrackKind::Standard);
let m = TrackMetrics::resolve(&g).expect("metrics");
let segments = m.segments(false);
assert_eq!(segments.len(), 2, "inactive trailing + active");
let inactive = segments[0];
let active = segments[1];
assert_eq!(active.role, SegmentRole::Active);
assert_eq!(inactive.role, SegmentRole::Inactive);
assert_eq!(active.start, 0.0);
assert_eq!(active.end, 92.0);
assert_eq!(inactive.start, 108.0);
assert_eq!(inactive.end, 200.0);
assert_eq!(
inactive.start - active.end,
2.0 * (HANDLE_WIDTH / 2.0 + HANDLE_GAP)
);
assert_eq!(active.start_corner, TRACK_CORNER_RADIUS);
assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
assert_eq!(inactive.start_corner, TRACK_INSIDE_CORNER);
assert_eq!(inactive.end_corner, TRACK_CORNER_RADIUS);
}
#[test]
fn the_outer_corner_clamps_to_half_the_track_thickness() {
let mut g = geometry(0.5, SliderTrackKind::Standard);
g.track_thickness = 6.0;
let m = TrackMetrics::resolve(&g).expect("metrics");
assert_eq!(m.corner, 3.0, "clamped from the 8dp token");
}
#[test]
fn a_thumb_at_either_end_drops_the_segment_it_would_swallow() {
let m = TrackMetrics::resolve(&geometry(0.0, SliderTrackKind::Standard)).expect("metrics");
let segments = m.segments(false);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].role, SegmentRole::Inactive);
let m = TrackMetrics::resolve(&geometry(1.0, SliderTrackKind::Standard)).expect("metrics");
let segments = m.segments(false);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].role, SegmentRole::Active);
}
#[test]
fn a_centered_track_grows_from_the_midpoint_and_keeps_both_inactive_ends() {
let g = geometry(0.75, SliderTrackKind::Centered);
let m = TrackMetrics::resolve(&g).expect("metrics");
assert_eq!(m.center_axis, 100.0);
let segments = m.segments(false);
assert_eq!(
segments.len(),
3,
"inactive leading, inactive trailing, active"
);
assert_eq!(segments[0].role, SegmentRole::Inactive);
assert_eq!(segments[1].role, SegmentRole::Inactive);
assert_eq!(segments[2].role, SegmentRole::Active);
let active = segments[2];
assert_eq!(active.start, 100.0, "the midpoint itself");
assert_eq!(active.end, 142.0, "thumb at 150 less the 8dp gap");
assert_eq!(active.start_corner, TRACK_INSIDE_CORNER);
assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
assert_eq!(segments[0].start, 0.0);
assert_eq!(segments[0].end, 92.0, "stops short of the midpoint gap");
assert_eq!(segments[1].start, 158.0, "thumb at 150 plus the gap");
assert_eq!(segments[1].end, 200.0);
}
#[test]
fn a_centered_track_below_the_midpoint_mirrors_the_active_span() {
let m = TrackMetrics::resolve(&geometry(0.25, SliderTrackKind::Centered)).expect("metrics");
let segments = m.segments(false);
let active = segments
.iter()
.find(|s| s.role == SegmentRole::Active)
.expect("an active span left of centre");
assert_eq!(active.start, 58.0, "thumb at 50 plus the gap");
assert_eq!(active.end, 100.0, "the midpoint itself");
}
fn range_geometry(low: f64, high: f64) -> SliderGeometry {
SliderGeometry {
active_start_fraction: low,
active_end_fraction: high,
..geometry(high, SliderTrackKind::Range)
}
}
#[test]
fn a_range_track_spans_between_two_thumbs_and_keeps_both_inactive_ends() {
let m = TrackMetrics::resolve(&range_geometry(0.25, 0.75)).expect("metrics");
let segments = m.segments(false);
assert_eq!(
segments.len(),
3,
"inactive leading, inactive trailing, active"
);
let active = segments[2];
assert_eq!(active.role, SegmentRole::Active);
assert_eq!(active.start, 58.0, "low thumb at 50 plus its gap");
assert_eq!(active.end, 142.0, "high thumb at 150 less its gap");
assert_eq!(
active.start_corner, TRACK_INSIDE_CORNER,
"both ends face a gap"
);
assert_eq!(active.end_corner, TRACK_INSIDE_CORNER);
assert_eq!((segments[0].start, segments[0].end), (0.0, 42.0));
assert_eq!((segments[1].start, segments[1].end), (158.0, 200.0));
}
#[test]
fn a_range_tracks_dots_clear_both_thumb_gaps() {
let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
let m = TrackMetrics::resolve(&range_geometry(0.25, 0.75)).expect("metrics");
let placed = dots(&m, &ticks, &dot_spec());
let primaries: Vec<f64> = placed.iter().map(|d| d.primary).collect();
assert_eq!(primaries, vec![8.0, 192.0, 100.0], "{placed:?}");
assert!(
placed[2].active,
"the surviving tick lies on the active span"
);
}
#[test]
fn a_range_track_hosts_no_inset_icons() {
assert!(
track_icon_placements(
200.0,
0.5,
SliderTrackKind::Range,
TRACK_ICON_SIZE,
[true; 4]
)
.is_empty()
);
}
#[test]
fn rtl_mirrors_which_end_of_each_segment_takes_the_outer_radius() {
let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
let ltr = m.segments(false);
let rtl = m.segments(true);
assert_eq!(ltr.len(), rtl.len());
for (l, r) in ltr.iter().zip(rtl.iter()) {
assert_eq!((l.start, l.end), (r.start, r.end), "spans are unmirrored");
assert_eq!(l.start_corner, r.end_corner);
assert_eq!(l.end_corner, r.start_corner);
}
}
#[test]
fn a_pressed_handle_narrows_the_gap_instantly() {
let mut g = geometry(0.5, SliderTrackKind::Standard);
g.handle_thickness = PRESSED_HANDLE_WIDTH;
let m = TrackMetrics::resolve(&g).expect("metrics");
assert_eq!(m.end_gap, PRESSED_HANDLE_WIDTH / 2.0 + HANDLE_GAP);
assert_eq!(m.segments(false)[1].end, 93.0, "active reaches 1dp further");
}
fn dot_spec() -> DotSpec {
DotSpec {
stop_size: STOP_INDICATOR_SIZE,
tick_size: TICK_SIZE,
edge_inset: STOP_INDICATOR_TRAILING_SPACE,
}
}
#[test]
fn a_continuous_track_keeps_only_the_stop_the_active_span_does_not_cover() {
let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
let placed = dots(&m, &[], &dot_spec());
assert_eq!(placed.len(), 1, "the leading stop is under the active span");
assert_eq!(placed[0].primary, 192.0);
assert_eq!(placed[0].size, STOP_INDICATOR_SIZE);
assert!(!placed[0].active, "an end stop always takes the stop role");
}
#[test]
fn a_thumb_at_the_maximum_leaves_no_stop_at_all() {
let m = TrackMetrics::resolve(&geometry(1.0, SliderTrackKind::Standard)).expect("metrics");
assert!(
dots(&m, &[], &dot_spec()).is_empty(),
"both ends sit on the active span or in its gap"
);
}
#[test]
fn discrete_ticks_skip_the_endpoints_and_the_handle_gap() {
let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
let m = TrackMetrics::resolve(&geometry(0.5, SliderTrackKind::Standard)).expect("metrics");
let placed = dots(&m, &ticks, &dot_spec());
assert_eq!(placed.len(), 3, "{placed:?}");
assert_eq!(placed[0].primary, 192.0, "the surviving end stop, first");
assert_eq!(placed[1].primary, 54.0, "tick at 0.25");
assert_eq!(placed[2].primary, 146.0, "tick at 0.75");
assert!(placed[1].active, "0.25 lies on the active span");
assert!(!placed[2].active, "0.75 lies past the thumb");
}
#[test]
fn a_longer_active_span_flips_more_ticks_to_the_active_role() {
let ticks = spec(0.0, 1.0, Some(4)).tick_fractions();
let m = TrackMetrics::resolve(&geometry(0.9, SliderTrackKind::Standard)).expect("metrics");
let placed = dots(&m, &ticks, &dot_spec());
assert_eq!(
placed.iter().filter(|d| d.active).count(),
3,
"every interior tick is inside a 90%-long active span: {placed:?}"
);
}
#[test]
fn the_thumb_box_is_centred_on_its_value_and_pill_rounded() {
let thumb = thumb_rect(100.0, 22.0, HANDLE_WIDTH, HANDLE_HEIGHT);
assert_eq!(thumb.x0, 98.0);
assert_eq!(thumb.x1, 102.0);
assert_eq!(thumb.y0, 0.0);
assert_eq!(thumb.y1, 44.0);
assert_eq!(thumb_radius(thumb), HANDLE_HEIGHT / 2.0);
let ring = focus_ring_rect(thumb);
assert_eq!(ring.width(), HANDLE_WIDTH + FOCUS_INFLATE);
assert_eq!(ring.height(), HANDLE_HEIGHT + FOCUS_INFLATE);
}
#[test]
fn the_value_indicator_floats_above_the_control_box() {
let origin = value_indicator_origin(100.0);
assert_eq!(origin.x, 76.0);
assert_eq!(
origin.y,
-(VALUE_INDICATOR_BOTTOM_SPACE + VALUE_INDICATOR_LIFT)
);
assert!(origin.y < 0.0, "it overflows the control's own box");
}
fn dock_input(thumb_primary: f64) -> IconDockInput {
IconDockInput {
extent: 200.0,
thumb_primary,
icon_size: ICON_SIZE_DEFAULT,
edge_inset: ICON_EDGE_INSET,
handle_thickness: HANDLE_WIDTH,
position: SliderIconPosition::End,
reverse: false,
}
}
#[test]
fn the_end_icon_rests_at_the_trailing_edge_until_the_thumb_nears_it() {
let far = icon_dock(&dock_input(20.0));
assert_eq!(far.resting_center, 180.0, "extent - inset - half the icon");
assert!(!far.docked);
assert_eq!(far.center_at(0.0), 180.0, "resting while undocked");
let near = icon_dock(&dock_input(160.0));
assert!(near.docked, "within the dock distance limit");
assert_eq!(near.docked_target, 160.0 - (2.0 + 12.0 + 12.0));
assert_eq!(near.center_at(1.0), 134.0);
}
#[test]
fn a_docked_icon_stays_inside_the_track_and_reports_which_segment_it_is_over() {
let dock = icon_dock(&dock_input(4.0));
assert_eq!(dock.center_at(1.0), 12.0, "clamped to half the icon");
assert!(
!dock.over_active(180.0, 4.0, false),
"resting past the thumb is over the inactive segment"
);
assert!(dock.over_active(2.0, 4.0, false));
}
#[test]
fn a_track_narrower_than_its_icon_docks_without_panicking() {
let mut input = dock_input(4.0);
input.extent = 10.0;
let dock = icon_dock(&input);
assert_eq!(
dock.min_center, -2.0,
"ordered: extent - icon_half < icon_half"
);
assert_eq!(dock.max_center, 12.0);
assert_eq!(dock.center_at(0.0), dock.min_center);
assert_eq!(dock.center_at(1.0), dock.min_center);
assert!(dock.center_at(0.5).is_finite());
}
#[test]
fn a_start_positioned_icon_rests_at_the_leading_edge() {
let mut input = dock_input(180.0);
input.position = SliderIconPosition::Start;
assert_eq!(icon_dock(&input).resting_center, 20.0);
}
#[test]
fn inset_track_icons_place_per_segment_and_drop_out_of_a_short_one() {
let all = [true; 4];
let placed =
track_icon_placements(200.0, 0.5, SliderTrackKind::Standard, TRACK_ICON_SIZE, all);
assert_eq!(placed.len(), 4);
assert_eq!(placed[0].primary, TRACK_ICON_INSET);
assert_eq!(
placed[1].primary,
100.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
);
assert_eq!(placed[2].primary, 104.0);
assert_eq!(
placed[3].primary,
200.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
);
assert!(placed[0].active && placed[1].active);
assert!(!placed[2].active && !placed[3].active);
let squeezed =
track_icon_placements(200.0, 0.05, SliderTrackKind::Standard, TRACK_ICON_SIZE, all);
assert!(squeezed.iter().all(|p| !p.active), "{squeezed:?}");
}
#[test]
fn a_slot_with_no_icon_is_never_placed() {
let only_active_start = [true, false, false, false];
let placed = track_icon_placements(
200.0,
0.5,
SliderTrackKind::Standard,
TRACK_ICON_SIZE,
only_active_start,
);
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].slot, TrackIconSlot::ActiveStart);
}
#[test]
fn a_centered_track_hosts_active_slot_icons_only() {
let placed = track_icon_placements(
200.0,
0.75,
SliderTrackKind::Centered,
TRACK_ICON_SIZE,
[true; 4],
);
assert_eq!(placed.len(), 2);
assert_eq!(placed[0].slot, TrackIconSlot::ActiveStart);
assert_eq!(
placed[0].primary,
100.0 - TRACK_ICON_SIZE - TRACK_ICON_INSET
);
assert_eq!(placed[1].slot, TrackIconSlot::ActiveEnd);
assert_eq!(placed[1].primary, 150.0 + TRACK_ICON_INSET);
}
fn drag_ctx(spec: ValueSpec, current: f64) -> DragCtx {
DragCtx {
spec,
current,
extent: 200.0,
reverse: false,
}
}
#[test]
fn down_move_up_reports_values_then_settles() {
let spec = spec(0.0, 100.0, None);
let mut drag = Drag::default();
let down = drag.down(50.0, &drag_ctx(spec, 0.0));
assert!(down.handled && down.capture);
assert_eq!(down.value, Some(25.0));
assert!(!down.end);
assert!(drag.pressed && drag.dragging);
let moved = drag.moved(150.0, &drag_ctx(spec, 25.0));
assert_eq!(moved.value, Some(75.0));
assert!(!moved.capture, "the capture is already open");
let up = drag.up();
assert!(up.end, "settling owes on_change_end");
assert_eq!(up.value, None, "and reports no new value");
assert!(!drag.pressed && !drag.dragging);
}
#[test]
fn an_uncaptured_move_or_up_is_ignored_outright() {
let spec = ValueSpec::default();
let mut drag = Drag::default();
assert_eq!(
drag.moved(100.0, &drag_ctx(spec, 0.0)),
DragOutcome::default()
);
assert_eq!(drag.up(), DragOutcome::default());
assert_eq!(drag.cancel(), DragOutcome::default());
assert!(!drag.pressed, "a hover move never presses");
}
#[test]
fn cancel_disarms_without_owing_a_change_end() {
let spec = ValueSpec::default();
let mut drag = Drag::default();
drag.down(100.0, &drag_ctx(spec, 0.0));
let cancelled = drag.cancel();
assert!(cancelled.handled);
assert!(!cancelled.end, "a Cancel arm may never reach app state");
assert!(!drag.pressed && !drag.dragging);
}
#[test]
fn a_move_that_lands_on_the_held_value_reports_nothing() {
let spec = spec(0.0, 10.0, Some(10));
let mut drag = Drag::default();
drag.down(100.0, &drag_ctx(spec, 0.0));
assert_eq!(drag.moved(101.0, &drag_ctx(spec, 5.0)).value, None);
assert_eq!(drag.moved(121.0, &drag_ctx(spec, 5.0)).value, Some(6.0));
}
fn ft_ms(ms: u64) -> FrameTime {
FrameTime::from_nanos(ms * 1_000_000)
}
#[test]
fn the_haptic_scheduler_paces_continuous_ticks_and_lets_discrete_ones_through() {
let mut sched = HapticScheduler::default();
assert!(!sched.has_pending());
sched.queue(HapticSignal::SliderTick, HapticCadence::Throttled);
assert!(sched.has_pending());
assert_eq!(sched.take(ft_ms(0)), Some(HapticSignal::SliderTick));
assert!(!sched.has_pending(), "taking drains the queue");
assert_eq!(
sched.take(ft_ms(100)),
None,
"nothing queued, nothing fires"
);
let mut fired = 0;
for frame in 1..=12 {
sched.queue(HapticSignal::SliderTick, HapticCadence::Throttled);
if sched.take(ft_ms(frame * 16)).is_some() {
fired += 1;
}
}
assert_eq!(
fired, 3,
"12 frames * 16ms = 192ms after the first tick: 64/128/192"
);
let mut sched = HapticScheduler::default();
let mut fired = 0;
for frame in 0..12 {
sched.queue(HapticSignal::SliderTick, HapticCadence::Immediate);
if sched.take(ft_ms(frame)).is_some() {
fired += 1;
}
}
assert_eq!(fired, 12, "a per-division tick is never throttled");
}
#[test]
fn a_second_queue_before_the_next_paint_collapses_into_one_tick() {
let mut sched = HapticScheduler::default();
sched.queue(HapticSignal::Light, HapticCadence::Immediate);
sched.queue(HapticSignal::Heavy, HapticCadence::Immediate);
assert_eq!(sched.take(ft_ms(0)), Some(HapticSignal::Heavy));
assert_eq!(sched.take(ft_ms(1)), None);
}
}