Another project
1use core::fmt::Debug;
2use core::num::NonZeroU8;
3
4use crate::a11y::{AccessNode, AccessRange, Role};
5use crate::frame::{FrameCtx, InteractDeclaration};
6use crate::hit_test::{Interaction, Sense};
7use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
8use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
9use crate::strings::StringKey;
10use crate::theme::{Border, Step12, StrokeWidth};
11use crate::widget_id::WidgetId;
12
13use super::keys::{TakeKey, take_key};
14use super::paint::{GlyphMark, WidgetPaint};
15use super::visuals::push_focus_ring;
16
17pub trait SliderScalar: Copy + Debug + Default + PartialOrd {
18 #[must_use]
19 fn to_unit(self, range: SliderRange<Self>) -> f64;
20 #[must_use]
21 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self;
22 #[must_use]
23 fn to_f64(self) -> f64;
24 #[must_use]
25 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self;
26 #[must_use]
27 fn clamp_to(self, range: SliderRange<Self>) -> Self;
28 #[must_use]
29 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self;
30}
31
32#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
33pub enum SliderRangeError {
34 #[error("slider range min must be strictly less than max")]
35 NotOrdered,
36 #[error("slider range bounds must be comparable")]
37 NotComparable,
38}
39
40#[derive(Copy, Clone, Debug, PartialEq)]
41pub struct SliderRange<T> {
42 min: T,
43 max: T,
44}
45
46impl<T: SliderScalar> SliderRange<T> {
47 pub fn try_new(min: T, max: T) -> Result<Self, SliderRangeError> {
48 match min.partial_cmp(&max) {
49 Some(core::cmp::Ordering::Less) => Ok(Self { min, max }),
50 Some(_) => Err(SliderRangeError::NotOrdered),
51 None => Err(SliderRangeError::NotComparable),
52 }
53 }
54
55 #[must_use]
56 pub fn min(self) -> T {
57 self.min
58 }
59
60 #[must_use]
61 pub fn max(self) -> T {
62 self.max
63 }
64}
65
66#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
67pub enum SliderStepError {
68 #[error("slider step must be strictly positive")]
69 NotPositive,
70}
71
72#[derive(Copy, Clone, Debug, PartialEq)]
73pub struct SliderStep<T>(T);
74
75impl<T: SliderScalar> SliderStep<T> {
76 pub fn try_new(value: T) -> Result<Self, SliderStepError> {
77 match value.partial_cmp(&T::default()) {
78 Some(core::cmp::Ordering::Greater) => Ok(Self(value)),
79 _ => Err(SliderStepError::NotPositive),
80 }
81 }
82
83 #[must_use]
84 pub fn value(self) -> T {
85 self.0
86 }
87}
88
89#[derive(Copy, Clone, Debug, PartialEq, Eq)]
90pub struct SliderCoarseStep(NonZeroU8);
91
92impl SliderCoarseStep {
93 pub const DEFAULT: Self = match NonZeroU8::new(10) {
94 Some(n) => Self(n),
95 None => panic!("default coarse step must be non-zero"),
96 };
97
98 #[must_use]
99 pub const fn new(value: NonZeroU8) -> Self {
100 Self(value)
101 }
102
103 #[must_use]
104 pub const fn get(self) -> NonZeroU8 {
105 self.0
106 }
107}
108
109pub struct Slider<T: SliderScalar> {
110 pub id: WidgetId,
111 pub rect: LayoutRect,
112 pub label: StringKey,
113 pub value: T,
114 pub range: SliderRange<T>,
115 pub step: SliderStep<T>,
116 pub coarse_multiplier: SliderCoarseStep,
117 pub disabled: bool,
118}
119
120impl<T: SliderScalar> Slider<T> {
121 #[must_use]
122 pub fn new(
123 id: WidgetId,
124 rect: LayoutRect,
125 label: StringKey,
126 value: T,
127 range: SliderRange<T>,
128 step: SliderStep<T>,
129 ) -> Self {
130 Self {
131 id,
132 rect,
133 label,
134 value,
135 range,
136 step,
137 coarse_multiplier: SliderCoarseStep::DEFAULT,
138 disabled: false,
139 }
140 }
141
142 #[must_use]
143 pub fn disabled(self, disabled: bool) -> Self {
144 Self { disabled, ..self }
145 }
146}
147
148#[derive(Clone, Debug, PartialEq)]
149pub struct SliderResponse<T: SliderScalar> {
150 pub interaction: Interaction,
151 pub value: T,
152 pub changed: bool,
153 pub paint: Vec<WidgetPaint>,
154}
155
156#[must_use]
157#[allow(
158 clippy::needless_pass_by_value,
159 reason = "destructure consumes the slider"
160)]
161pub fn show_slider<T: SliderScalar>(
162 ctx: &mut FrameCtx<'_>,
163 slider: Slider<T>,
164) -> SliderResponse<T> {
165 let Slider {
166 id,
167 rect,
168 label,
169 value: initial_value,
170 range,
171 step,
172 coarse_multiplier,
173 disabled,
174 } = slider;
175 let interactive = !disabled;
176 let interaction = ctx.interact(
177 InteractDeclaration::new(id, rect, Sense::DRAGGABLE)
178 .focusable(interactive)
179 .disabled(!interactive),
180 );
181 let mut value = initial_value;
182 let mut changed = false;
183
184 if interactive
185 && (interaction.click() || interaction.drag_start() || interaction.pressed())
186 && let Some(unit) = pointer_unit(rect, ctx.input)
187 {
188 let next = T::from_unit(unit, range)
189 .snap_to_step(step, range)
190 .clamp_to(range);
191 if next != value {
192 value = next;
193 changed = true;
194 }
195 }
196
197 let live_focused = ctx.is_focused(id);
198 if interactive
199 && live_focused
200 && let Some(event) = take_key(ctx.input, &KEYBOARD_TARGETS)
201 {
202 let next = apply_key(value, range, step, coarse_multiplier, event);
203 if next != value {
204 value = next;
205 changed = true;
206 }
207 }
208
209 ctx.a11y.push(
210 id,
211 rect,
212 AccessNode::new(Role::Slider)
213 .with_label(label)
214 .with_disabled(!interactive)
215 .with_range(AccessRange {
216 value: value.to_f64(),
217 min: range.min().to_f64(),
218 max: range.max().to_f64(),
219 step: step.value().to_f64(),
220 }),
221 );
222
223 let paint = build_paint(ctx, rect, range, disabled, value, interaction, live_focused);
224 SliderResponse {
225 interaction,
226 value,
227 changed,
228 paint,
229 }
230}
231
232const KEYBOARD_TARGETS: [TakeKey; 10] = [
233 TakeKey::named(NamedKey::ArrowLeft),
234 TakeKey::named(NamedKey::ArrowRight),
235 TakeKey::named(NamedKey::ArrowUp),
236 TakeKey::named(NamedKey::ArrowDown),
237 TakeKey::named(NamedKey::Home),
238 TakeKey::named(NamedKey::End),
239 TakeKey::new(KeyCode::Named(NamedKey::ArrowLeft), ModifierMask::SHIFT),
240 TakeKey::new(KeyCode::Named(NamedKey::ArrowRight), ModifierMask::SHIFT),
241 TakeKey::new(KeyCode::Named(NamedKey::ArrowUp), ModifierMask::SHIFT),
242 TakeKey::new(KeyCode::Named(NamedKey::ArrowDown), ModifierMask::SHIFT),
243];
244
245fn pointer_unit(rect: LayoutRect, input: &crate::input::InputSnapshot) -> Option<f64> {
246 let width = rect.size.width.value();
247 if width <= 0.0 {
248 return None;
249 }
250 let pointer = input.pointer?.position;
251 let local_x = pointer.x.value() - rect.origin.x.value();
252 Some(f64::from((local_x / width).clamp(0.0, 1.0)))
253}
254
255fn apply_key<T: SliderScalar>(
256 value: T,
257 range: SliderRange<T>,
258 step: SliderStep<T>,
259 coarse: SliderCoarseStep,
260 event: KeyEvent,
261) -> T {
262 let magnitude = if event.modifiers.contains(ModifierMask::SHIFT) {
263 i32::from(coarse.get().get())
264 } else {
265 1
266 };
267 let stepped = |signed: i32| {
268 value
269 .step_by(step, signed)
270 .snap_to_step(step, range)
271 .clamp_to(range)
272 };
273 match event.code {
274 KeyCode::Named(NamedKey::ArrowLeft | NamedKey::ArrowDown) => stepped(-magnitude),
275 KeyCode::Named(NamedKey::ArrowRight | NamedKey::ArrowUp) => stepped(magnitude),
276 KeyCode::Named(NamedKey::Home) => range.min(),
277 KeyCode::Named(NamedKey::End) => range.max(),
278 _ => value,
279 }
280}
281
282fn build_paint<T: SliderScalar>(
283 ctx: &FrameCtx<'_>,
284 rect: LayoutRect,
285 range: SliderRange<T>,
286 disabled: bool,
287 value: T,
288 interaction: Interaction,
289 live_focused: bool,
290) -> Vec<WidgetPaint> {
291 let neutral = ctx.theme().colors.neutral;
292 let accent = ctx.theme().colors.accent;
293 let radius = ctx.theme().radius.pill;
294 let track_height = LayoutPx::new(4.0);
295 let track_y = LayoutPx::new(
296 rect.origin.y.value() + rect.size.height.value() / 2.0 - track_height.value() / 2.0,
297 );
298 let track_rect = LayoutRect::new(
299 LayoutPos::new(rect.origin.x, track_y),
300 LayoutSize::new(rect.size.width, track_height),
301 );
302 let unit = value.to_unit(range).clamp(0.0, 1.0);
303 #[allow(
304 clippy::cast_possible_truncation,
305 reason = "unit is clamped to [0, 1] before f32 narrowing"
306 )]
307 let unit_f32 = unit as f32;
308 let filled_width = LayoutPx::new(rect.size.width.value() * unit_f32);
309 let filled_rect = LayoutRect::new(
310 LayoutPos::new(rect.origin.x, track_y),
311 LayoutSize::new(filled_width, track_height),
312 );
313 let thumb_diameter = LayoutPx::new(rect.size.height.value().min(20.0));
314 let thumb_x = LayoutPx::new(
315 rect.origin.x.value() + rect.size.width.value() * unit_f32 - thumb_diameter.value() / 2.0,
316 );
317 let thumb_y = LayoutPx::new(
318 rect.origin.y.value() + rect.size.height.value() / 2.0 - thumb_diameter.value() / 2.0,
319 );
320 let thumb_rect = LayoutRect::new(
321 LayoutPos::new(thumb_x, thumb_y),
322 LayoutSize::new(thumb_diameter, thumb_diameter),
323 );
324 let track_fill = neutral.step(Step12::ELEMENT_BG);
325 let filled_fill = if disabled {
326 neutral.step(Step12::SUBTLE_BORDER)
327 } else {
328 accent.step(Step12::SOLID)
329 };
330 let mut paint = vec![
331 WidgetPaint::Surface {
332 rect: track_rect,
333 fill: track_fill,
334 border: Some(Border {
335 width: StrokeWidth::HAIRLINE,
336 color: neutral.step(Step12::BORDER),
337 }),
338 radius,
339 elevation: None,
340 },
341 WidgetPaint::Surface {
342 rect: filled_rect,
343 fill: filled_fill,
344 border: None,
345 radius,
346 elevation: None,
347 },
348 WidgetPaint::Surface {
349 rect: thumb_rect,
350 fill: if interaction.pressed() || interaction.hover() {
351 accent.step(Step12::HOVER_SOLID)
352 } else {
353 accent.step(Step12::SOLID)
354 },
355 border: Some(Border {
356 width: StrokeWidth::HAIRLINE,
357 color: neutral.step(Step12::BORDER),
358 }),
359 radius,
360 elevation: None,
361 },
362 WidgetPaint::Mark {
363 rect: thumb_rect,
364 kind: GlyphMark::SliderThumb,
365 color: ctx.theme().colors.contrast_text(filled_fill),
366 },
367 ];
368 push_focus_ring(ctx, &mut paint, thumb_rect, radius, live_focused);
369 paint
370}
371
372impl SliderScalar for f64 {
373 fn to_unit(self, range: SliderRange<Self>) -> f64 {
374 ((self - range.min) / (range.max - range.min)).clamp(0.0, 1.0)
375 }
376
377 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self {
378 range.min + (range.max - range.min) * unit.clamp(0.0, 1.0)
379 }
380
381 fn to_f64(self) -> f64 {
382 self
383 }
384
385 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self {
386 self + step.value() * f64::from(sign)
387 }
388
389 fn clamp_to(self, range: SliderRange<Self>) -> Self {
390 self.clamp(range.min, range.max)
391 }
392
393 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self {
394 let s = step.value();
395 range.min + ((self - range.min) / s).round() * s
396 }
397}
398
399impl SliderScalar for f32 {
400 fn to_unit(self, range: SliderRange<Self>) -> f64 {
401 f64::from((self - range.min) / (range.max - range.min)).clamp(0.0, 1.0)
402 }
403
404 #[allow(
405 clippy::cast_possible_truncation,
406 reason = "unit is clamped to [0, 1] before f32 narrowing"
407 )]
408 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self {
409 range.min + (range.max - range.min) * unit.clamp(0.0, 1.0) as f32
410 }
411
412 fn to_f64(self) -> f64 {
413 f64::from(self)
414 }
415
416 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self {
417 #[allow(
418 clippy::cast_precision_loss,
419 reason = "sign values fit f32 mantissa exactly"
420 )]
421 let mul = sign as f32;
422 self + step.value() * mul
423 }
424
425 fn clamp_to(self, range: SliderRange<Self>) -> Self {
426 self.clamp(range.min, range.max)
427 }
428
429 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self {
430 let s = step.value();
431 range.min + ((self - range.min) / s).round() * s
432 }
433}
434
435impl SliderScalar for i32 {
436 fn to_unit(self, range: SliderRange<Self>) -> f64 {
437 let span = i64::from(range.max) - i64::from(range.min);
438 let offset = i64::from(self) - i64::from(range.min);
439 #[allow(
440 clippy::cast_precision_loss,
441 reason = "i64 span fits in f64 for i32 inputs"
442 )]
443 let result = offset as f64 / span as f64;
444 result
445 }
446
447 fn to_f64(self) -> f64 {
448 f64::from(self)
449 }
450
451 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self {
452 let span = i64::from(range.max) - i64::from(range.min);
453 #[allow(
454 clippy::cast_precision_loss,
455 reason = "i64 span fits in f64 for i32 inputs"
456 )]
457 let span_f = span as f64;
458 #[allow(
459 clippy::cast_possible_truncation,
460 reason = "round + clamp keeps result in i64 range"
461 )]
462 let offset = (span_f * unit.clamp(0.0, 1.0)).round() as i64;
463 let raw = i64::from(range.min).saturating_add(offset);
464 #[allow(
465 clippy::cast_possible_truncation,
466 reason = "clamp before cast keeps result in i32"
467 )]
468 let clamped = raw.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
469 clamped
470 }
471
472 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self {
473 self.saturating_add(step.value().saturating_mul(sign))
474 }
475
476 fn clamp_to(self, range: SliderRange<Self>) -> Self {
477 self.clamp(range.min, range.max)
478 }
479
480 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self {
481 let offset = i64::from(self) - i64::from(range.min);
482 let step_i64 = i64::from(step.value());
483 #[allow(clippy::cast_precision_loss, reason = "i32-derived spans fit in f64")]
484 let n = (offset as f64 / step_i64 as f64).round();
485 #[allow(
486 clippy::cast_possible_truncation,
487 reason = "clamp before cast keeps result in i32"
488 )]
489 let snapped = i64::from(range.min)
490 .saturating_add((n as i64).saturating_mul(step_i64))
491 .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
492 snapped
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use std::sync::Arc;
499
500 use super::{
501 Slider, SliderRange, SliderRangeError, SliderScalar, SliderStep, SliderStepError,
502 show_slider,
503 };
504 use crate::focus::FocusManager;
505 use crate::frame::FrameCtx;
506 use crate::hit_test::{HitFrame, HitState};
507 use crate::hotkey::HotkeyTable;
508 use crate::input::{FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey};
509 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
510 use crate::strings::{StringKey, StringTable};
511 use crate::theme::Theme;
512 use crate::widget_id::{WidgetId, WidgetKey};
513
514 const LABEL: StringKey = StringKey::new("slider.label");
515
516 fn rect() -> LayoutRect {
517 LayoutRect::new(
518 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
519 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(20.0)),
520 )
521 }
522
523 fn slider_id() -> WidgetId {
524 WidgetId::ROOT.child(WidgetKey::new("slider"))
525 }
526
527 fn unit_range() -> SliderRange<f64> {
528 let Ok(range) = SliderRange::try_new(0.0_f64, 100.0) else {
529 panic!("test range must be valid");
530 };
531 range
532 }
533
534 fn unit_step() -> SliderStep<f64> {
535 let Ok(step) = SliderStep::try_new(1.0_f64) else {
536 panic!("test step must be valid");
537 };
538 step
539 }
540
541 fn run(value: f64, events: Vec<KeyEvent>) -> super::SliderResponse<f64> {
542 let theme = Arc::new(Theme::light());
543 let mut focus = FocusManager::new();
544 focus.register_focusable(slider_id());
545 focus.request_focus(slider_id());
546 focus.end_frame();
547 let table = HotkeyTable::new();
548 let mut hits = HitFrame::new();
549 let prev = HitState::new();
550 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
551 input.keys_pressed = events;
552 let widget = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), unit_step());
553 let mut shaper = bone_text::Shaper::new();
554 let mut a11y = crate::a11y::AccessTreeBuilder::new();
555 let mut ctx = FrameCtx::new(
556 theme,
557 &mut input,
558 &mut focus,
559 &table,
560 StringTable::empty(),
561 &mut hits,
562 &prev,
563 &mut a11y,
564 &mut shaper,
565 );
566 show_slider(&mut ctx, widget)
567 }
568
569 #[test]
570 fn arrow_right_steps_up() {
571 let response = run(
572 10.0,
573 vec![KeyEvent::new(
574 KeyCode::Named(NamedKey::ArrowRight),
575 ModifierMask::NONE,
576 )],
577 );
578 assert!((response.value - 11.0).abs() < 1e-9);
579 assert!(response.changed);
580 }
581
582 #[test]
583 fn arrow_left_steps_down() {
584 let response = run(
585 10.0,
586 vec![KeyEvent::new(
587 KeyCode::Named(NamedKey::ArrowLeft),
588 ModifierMask::NONE,
589 )],
590 );
591 assert!((response.value - 9.0).abs() < 1e-9);
592 }
593
594 #[test]
595 fn shift_arrow_uses_coarse_multiplier() {
596 let response = run(
597 10.0,
598 vec![KeyEvent::new(
599 KeyCode::Named(NamedKey::ArrowRight),
600 ModifierMask::SHIFT,
601 )],
602 );
603 assert!((response.value - 20.0).abs() < 1e-9);
604 }
605
606 #[test]
607 fn home_jumps_to_min() {
608 let response = run(
609 42.0,
610 vec![KeyEvent::new(
611 KeyCode::Named(NamedKey::Home),
612 ModifierMask::NONE,
613 )],
614 );
615 assert!((response.value - 0.0).abs() < 1e-9);
616 }
617
618 #[test]
619 fn end_jumps_to_max() {
620 let response = run(
621 42.0,
622 vec![KeyEvent::new(
623 KeyCode::Named(NamedKey::End),
624 ModifierMask::NONE,
625 )],
626 );
627 assert!((response.value - 100.0).abs() < 1e-9);
628 }
629
630 #[test]
631 fn arrow_keys_clamp_at_range_edges() {
632 let response = run(
633 0.0,
634 vec![KeyEvent::new(
635 KeyCode::Named(NamedKey::ArrowLeft),
636 ModifierMask::NONE,
637 )],
638 );
639 assert!((response.value - 0.0).abs() < 1e-9);
640 }
641
642 #[test]
643 fn idle_keeps_value_unchanged() {
644 let response = run(50.0, vec![]);
645 assert!((response.value - 50.0).abs() < 1e-9);
646 assert!(!response.changed);
647 }
648
649 #[test]
650 fn unit_conversions_round_trip() {
651 let range = unit_range();
652 [0.0_f64, 25.0, 50.0, 75.0, 100.0]
653 .into_iter()
654 .for_each(|v| {
655 let unit = v.to_unit(range);
656 let back = f64::from_unit(unit, range);
657 assert!((back - v).abs() < 1e-9);
658 });
659 }
660
661 #[test]
662 fn integer_slider_steps_by_one() {
663 let Ok(range) = SliderRange::try_new(0_i32, 10) else {
664 panic!("range valid");
665 };
666 let Ok(step) = SliderStep::try_new(2_i32) else {
667 panic!("step valid");
668 };
669 let unit_at_5 = 5_i32.to_unit(range);
670 assert!((unit_at_5 - 0.5).abs() < 1e-9);
671 assert_eq!(i32::from_unit(0.7, range), 7);
672 assert_eq!(5_i32.step_by(step, -1).clamp_to(range), 3);
673 }
674
675 #[test]
676 fn range_try_new_rejects_reversed() {
677 assert_eq!(
678 SliderRange::try_new(10.0_f64, 5.0),
679 Err(SliderRangeError::NotOrdered),
680 );
681 }
682
683 #[test]
684 fn range_try_new_rejects_equal() {
685 assert_eq!(
686 SliderRange::try_new(5.0_f64, 5.0),
687 Err(SliderRangeError::NotOrdered),
688 );
689 }
690
691 #[test]
692 fn range_try_new_rejects_nan() {
693 assert_eq!(
694 SliderRange::try_new(f64::NAN, 1.0),
695 Err(SliderRangeError::NotComparable),
696 );
697 assert_eq!(
698 SliderRange::try_new(0.0, f64::NAN),
699 Err(SliderRangeError::NotComparable),
700 );
701 }
702
703 #[test]
704 fn step_try_new_rejects_zero() {
705 assert_eq!(
706 SliderStep::try_new(0.0_f64),
707 Err(SliderStepError::NotPositive),
708 );
709 }
710
711 #[test]
712 fn step_try_new_rejects_negative() {
713 assert_eq!(
714 SliderStep::try_new(-1.0_f64),
715 Err(SliderStepError::NotPositive),
716 );
717 assert_eq!(
718 SliderStep::try_new(-1_i32),
719 Err(SliderStepError::NotPositive)
720 );
721 }
722
723 #[test]
724 fn step_try_new_rejects_nan() {
725 assert_eq!(
726 SliderStep::try_new(f64::NAN),
727 Err(SliderStepError::NotPositive),
728 );
729 }
730
731 fn run_pointer_press_at_with(
732 initial: f64,
733 x: f32,
734 step: SliderStep<f64>,
735 ) -> super::SliderResponse<f64> {
736 use crate::hit_test::resolve;
737 use crate::input::{PointerButton, PointerButtonMask, PointerSample};
738 let theme = Arc::new(Theme::light());
739 let mut focus = FocusManager::new();
740 let table = HotkeyTable::new();
741 let mut prev_state = HitState::new();
742 let mut value = initial;
743 let pos = LayoutPos::new(LayoutPx::new(x), LayoutPx::new(10.0));
744 let mut frame = |pressed: PointerButtonMask| -> super::SliderResponse<f64> {
745 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
746 snap.pointer = Some(PointerSample::new(pos));
747 snap.buttons_pressed = pressed;
748 let mut hits = HitFrame::new();
749 let response = {
750 let widget = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), step);
751 let mut shaper = bone_text::Shaper::new();
752 let mut a11y = crate::a11y::AccessTreeBuilder::new();
753 let mut ctx = FrameCtx::new(
754 theme.clone(),
755 &mut snap,
756 &mut focus,
757 &table,
758 StringTable::empty(),
759 &mut hits,
760 &prev_state,
761 &mut a11y,
762 &mut shaper,
763 );
764 show_slider(&mut ctx, widget)
765 };
766 value = response.value;
767 prev_state = resolve(&prev_state, &hits, &snap, focus.focused());
768 response
769 };
770 let _ = frame(PointerButtonMask::just(PointerButton::Primary));
771 frame(PointerButtonMask::EMPTY)
772 }
773
774 #[test]
775 fn pointer_press_near_left_edge_snaps_value_to_min() {
776 let response = run_pointer_press_at_with(50.0, 1.0, unit_step());
777 assert!(
778 response.value < 5.0,
779 "expected near-min, got {}",
780 response.value
781 );
782 assert!(response.changed);
783 }
784
785 #[test]
786 fn pointer_press_near_right_edge_snaps_value_to_max() {
787 let response = run_pointer_press_at_with(50.0, 199.0, unit_step());
788 assert!(
789 response.value > 95.0,
790 "expected near-max, got {}",
791 response.value
792 );
793 assert!(response.changed);
794 }
795
796 #[test]
797 fn pointer_press_at_known_offset_yields_expected_value() {
798 let response = run_pointer_press_at_with(0.0, 50.0, unit_step());
799 assert!(
800 (response.value - 25.0).abs() < 1.0,
801 "got {}",
802 response.value
803 );
804 }
805
806 #[test]
807 fn pointer_drag_snaps_float_to_step_grid() {
808 let Ok(step5) = SliderStep::try_new(5.0_f64) else {
809 panic!("step valid");
810 };
811 let response = run_pointer_press_at_with(0.0, 53.0, step5);
812 assert!(
813 (response.value - 25.0).abs() < 1e-9,
814 "expected snap to nearest 5, got {}",
815 response.value,
816 );
817 }
818
819 #[test]
820 fn fine_step_approximates_continuous_drag() {
821 let Ok(fine) = SliderStep::try_new(0.001_f64) else {
822 panic!("step valid");
823 };
824 let response = run_pointer_press_at_with(0.0, 53.0, fine);
825 assert!(
826 (response.value - 26.5).abs() < 0.5,
827 "expected near 26.5, got {}",
828 response.value,
829 );
830 }
831
832 #[test]
833 fn integer_pointer_drag_snaps_to_step_grid() {
834 let Ok(range) = SliderRange::try_new(0_i32, 20) else {
835 panic!("range valid");
836 };
837 let Ok(step) = SliderStep::try_new(5_i32) else {
838 panic!("step valid");
839 };
840 let raw = i32::from_unit(0.55, range);
841 let snapped = raw.snap_to_step(step, range);
842 assert_eq!(snapped, 10, "0.55 rounds to 11 then snaps to 10");
843 }
844
845 #[test]
846 fn keyboard_arrow_snaps_off_grid_value_to_grid() {
847 let Ok(step) = SliderStep::try_new(3.0_f64) else {
848 panic!("step valid");
849 };
850 let response = run_with_step(
851 5.0,
852 step,
853 vec![KeyEvent::new(
854 KeyCode::Named(NamedKey::ArrowRight),
855 ModifierMask::NONE,
856 )],
857 );
858 assert!(
859 (response.value - 9.0).abs() < 1e-9,
860 "5 + 3 = 8, snap-to-3-grid = 9, got {}",
861 response.value,
862 );
863 }
864
865 fn run_with_step(
866 value: f64,
867 step: SliderStep<f64>,
868 events: Vec<KeyEvent>,
869 ) -> super::SliderResponse<f64> {
870 let theme = Arc::new(Theme::light());
871 let mut focus = FocusManager::new();
872 focus.register_focusable(slider_id());
873 focus.request_focus(slider_id());
874 focus.end_frame();
875 let table = HotkeyTable::new();
876 let mut hits = HitFrame::new();
877 let prev = HitState::new();
878 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
879 input.keys_pressed = events;
880 let widget = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), step);
881 let mut shaper = bone_text::Shaper::new();
882 let mut a11y = crate::a11y::AccessTreeBuilder::new();
883 let mut ctx = FrameCtx::new(
884 theme,
885 &mut input,
886 &mut focus,
887 &table,
888 StringTable::empty(),
889 &mut hits,
890 &prev,
891 &mut a11y,
892 &mut shaper,
893 );
894 show_slider(&mut ctx, widget)
895 }
896}