Skip to main content

tessera_components/slider/
interaction.rs

1use tessera_foundation::gesture::{DragRecognizer, TapRecognizer};
2use tessera_ui::{
3    AccessibilityActionHandler, AccessibilityNode, CallbackWith, ComputedData, FocusRequester,
4    KeyboardInput, KeyboardInputModifierNode, PointerInput, Px, PxPosition, State,
5    accesskit::{Action, Role},
6    winit::{
7        event::ElementState,
8        keyboard::{Key, ModifiersState, NamedKey},
9    },
10};
11
12use super::{
13    ACCESSIBILITY_STEP, SliderArgs, SliderController, SliderLayout, SliderOrientation,
14    axis::SliderAxis,
15};
16
17#[derive(Clone, Copy, PartialEq)]
18pub(super) enum SliderKeyboardModel {
19    Single(State<f32>),
20    Start(State<(f32, f32)>),
21    End(State<(f32, f32)>),
22}
23
24impl SliderKeyboardModel {
25    fn snapshot(self) -> (f32, f32, f32) {
26        match self {
27            Self::Single(value) => (value.get(), 0.0, 1.0),
28            Self::Start(value) => {
29                let (start, end) = value.get();
30                (start, 0.0, end)
31            }
32            Self::End(value) => {
33                let (start, end) = value.get();
34                (end, start, 1.0)
35            }
36        }
37    }
38
39    fn update(self, next: f32) {
40        match self {
41            Self::Single(value) => value.set(next),
42            Self::Start(value) => value.with_mut(|value| value.0 = next),
43            Self::End(value) => value.with_mut(|value| value.1 = next),
44        }
45    }
46}
47
48pub(super) struct SliderKeyboardModifierNode {
49    pub axis: SliderAxis,
50    pub model: SliderKeyboardModel,
51    pub steps: usize,
52    pub disabled: bool,
53    pub space_held: State<bool>,
54    pub on_change: CallbackWith<f32>,
55}
56
57fn key_target(
58    axis: SliderAxis,
59    key: &Key,
60    value: f32,
61    min: f32,
62    max: f32,
63    steps: usize,
64    larger: bool,
65) -> Option<f32> {
66    let intervals = if steps == 0 {
67        100
68    } else {
69        steps.saturating_add(1)
70    };
71    let delta = 1.0 / intervals as f32;
72    let page = (intervals / 10).clamp(1, 10) as f32;
73    let arrow = if larger { page * delta } else { delta };
74    let sign = if axis.reversed { -1.0 } else { 1.0 };
75    let vertical = axis.orientation == SliderOrientation::Vertical;
76    let next = match key {
77        Key::Named(NamedKey::ArrowUp) if vertical => value - sign * arrow,
78        Key::Named(NamedKey::ArrowDown) if vertical => value + sign * arrow,
79        Key::Named(NamedKey::ArrowRight | NamedKey::ArrowLeft) if vertical => return None,
80        Key::Named(NamedKey::ArrowRight) => value + sign * arrow,
81        Key::Named(NamedKey::ArrowLeft) => value - sign * arrow,
82        Key::Named(NamedKey::PageUp) if vertical => value - sign * page * delta,
83        Key::Named(NamedKey::PageDown) if vertical => value + sign * page * delta,
84        Key::Named(NamedKey::ArrowUp) => value + arrow,
85        Key::Named(NamedKey::ArrowDown) => value - arrow,
86        Key::Named(NamedKey::PageUp) => value + page * delta,
87        Key::Named(NamedKey::PageDown) => value - page * delta,
88        Key::Named(NamedKey::Home) => return Some(min),
89        Key::Named(NamedKey::End) => return Some(max),
90        _ => return None,
91    };
92    Some(snap_fraction(next, steps).clamp(min, max))
93}
94
95impl SliderKeyboardModifierNode {
96    fn handle_key(&self, key: &Key, state: ElementState, modifiers: ModifiersState) -> bool {
97        if self.disabled || modifiers.control_key() || modifiers.alt_key() || modifiers.super_key()
98        {
99            return false;
100        }
101        if *key == Key::Named(NamedKey::Space) {
102            let held = state == ElementState::Pressed;
103            if self.space_held.get() != held {
104                self.space_held.set(held);
105            }
106            return true;
107        }
108        let (value, min, max) = self.model.snapshot();
109        let value = if value.is_nan() {
110            min
111        } else {
112            value.clamp(min, max)
113        };
114        let larger = self.space_held.get();
115        let Some(next) = key_target(self.axis, key, value, min, max, self.steps, larger) else {
116            return false;
117        };
118        if state == ElementState::Pressed && (next - value).abs() > f32::EPSILON {
119            self.model.update(next);
120            self.on_change.call(next);
121        }
122        true
123    }
124}
125
126impl KeyboardInputModifierNode for SliderKeyboardModifierNode {
127    fn on_keyboard_input(&self, input: KeyboardInput<'_>) {
128        let modifiers = input.key_modifiers;
129        input
130            .keyboard_events
131            .retain(|event| !self.handle_key(&event.logical_key, event.state, modifiers));
132    }
133}
134
135pub(super) fn snap_fraction(value: f32, steps: usize) -> f32 {
136    let value = if value.is_nan() {
137        0.0
138    } else {
139        value.clamp(0.0, 1.0)
140    };
141    if steps == 0 {
142        return value;
143    }
144    let denom = steps as f32 + 1.0;
145    (value * denom).round() / denom
146}
147
148/// Helper: check if a cursor position is within the bounds of a component.
149pub(super) fn cursor_within_bounds(
150    cursor_pos: Option<PxPosition>,
151    computed: &ComputedData,
152) -> bool {
153    if let Some(pos) = cursor_pos {
154        let within_x = pos.x >= 0.0 && pos.x < computed.width.0 as f32;
155        let within_y = pos.y >= 0.0 && pos.y < computed.height.0 as f32;
156        within_x && within_y
157    } else {
158        false
159    }
160}
161
162/// Helper: compute normalized progress (0.0..1.0) from cursor X and overall
163/// width. Returns None when cursor is not available.
164pub(super) fn cursor_progress(
165    cursor_pos: Option<PxPosition>,
166    layout: &SliderLayout,
167) -> Option<f32> {
168    if layout.track_total_width.0 <= 0 {
169        return None;
170    }
171    cursor_pos.map(|pos| {
172        let cursor_x = layout.axis.input_main(pos, layout.component_width);
173        let half_handle = layout.handle_width.to_f32() / 2.0;
174        let start_x = layout.handle_gap.to_f32() + half_handle;
175        let fraction = (cursor_x - start_x) / layout.track_total_width.to_f32();
176        fraction.clamp(0.0, 1.0)
177    })
178}
179
180fn range_cursor_progress(
181    cursor_pos: Option<PxPosition>,
182    layout: &SliderLayout,
183    start_handle_width: Px,
184    end_handle_width: Px,
185) -> Option<f32> {
186    let cursor_pos = cursor_pos?;
187    let component_width = layout.component_width.to_f32();
188    let gap = layout.handle_gap.to_f32();
189    let start_half = start_handle_width.to_f32() / 2.0;
190    let end_half = end_handle_width.to_f32() / 2.0;
191    let track_total = (component_width - start_half - end_half - gap * 2.0).max(0.0);
192    if track_total <= 0.0 {
193        return None;
194    }
195    let start_x = gap + start_half;
196    let fraction =
197        (layout.axis.input_main(cursor_pos, layout.component_width) - start_x) / track_total;
198    Some(fraction.clamp(0.0, 1.0))
199}
200
201fn range_handle_center_x(
202    layout: &SliderLayout,
203    value: f32,
204    start_handle_width: Px,
205    end_handle_width: Px,
206) -> f32 {
207    let component_width = layout.component_width.to_f32();
208    let gap = layout.handle_gap.to_f32();
209    let start_half = start_handle_width.to_f32() / 2.0;
210    let end_half = end_handle_width.to_f32() / 2.0;
211    let track_total = (component_width - start_half - end_half - gap * 2.0).max(0.0);
212    let start_x = gap + start_half;
213    let raw = start_x + value.clamp(0.0, 1.0) * track_total;
214    raw.clamp(start_x, (component_width - gap - end_half).max(start_x))
215}
216
217pub(super) fn handle_slider_state(
218    input: &mut PointerInput,
219    tap_recognizer: State<TapRecognizer>,
220    drag_recognizer: State<DragRecognizer>,
221    state: State<SliderController>,
222    args: &SliderArgs,
223    layout: &SliderLayout,
224) {
225    if args.disabled {
226        let should_reset = state.with(|inner| inner.is_hovered || inner.is_dragging);
227        if should_reset {
228            state.with_mut(|inner| {
229                inner.is_hovered = false;
230                inner.is_dragging = false;
231            });
232        }
233        return;
234    }
235
236    let is_in_component = cursor_within_bounds(input.cursor_position_rel, &input.computed_data);
237
238    let hover_changed = state.with(|inner| inner.is_hovered != is_in_component);
239    if hover_changed {
240        state.with_mut(|inner| {
241            inner.is_hovered = is_in_component;
242        });
243    }
244
245    let tap_result = tap_recognizer.with_mut(|recognizer| {
246        recognizer.update(
247            input.pass,
248            input.pointer_changes.as_mut_slice(),
249            is_in_component,
250        )
251    });
252    let drag_result = drag_recognizer.with_mut(|recognizer| {
253        recognizer.update(
254            input.pass,
255            input.pointer_changes.as_mut_slice(),
256            is_in_component,
257        )
258    });
259
260    let mut new_value: Option<f32> = None;
261
262    handle_press_for_slider(
263        input,
264        state,
265        &mut new_value,
266        layout,
267        args.steps,
268        tap_result.pressed,
269    );
270    handle_drag_for_slider(
271        input,
272        state,
273        &mut new_value,
274        layout,
275        args.steps,
276        drag_result.started,
277        drag_result.updated,
278    );
279    if tap_result.released || drag_result.ended {
280        state.with_mut(|inner| inner.is_dragging = false);
281    }
282    notify_on_change(new_value, args);
283}
284
285fn handle_press_for_slider(
286    input: &mut PointerInput,
287    state: State<SliderController>,
288    new_value: &mut Option<f32>,
289    layout: &SliderLayout,
290    steps: usize,
291    pressed: bool,
292) {
293    if pressed {
294        state.with_mut(|inner| {
295            inner.focus.request_focus();
296        });
297        if let Some(v) = cursor_progress(input.cursor_position_rel, layout) {
298            *new_value = Some(snap_fraction(v, steps));
299        }
300    }
301}
302
303fn handle_drag_for_slider(
304    input: &PointerInput,
305    state: State<SliderController>,
306    new_value: &mut Option<f32>,
307    layout: &SliderLayout,
308    steps: usize,
309    drag_started: bool,
310    drag_updated: bool,
311) {
312    if drag_started {
313        state.with_mut(|inner| inner.is_dragging = true);
314    }
315
316    if (drag_updated || state.with(|s| s.is_dragging))
317        && let Some(v) = cursor_progress(input.cursor_position_rel, layout)
318    {
319        *new_value = Some(snap_fraction(v, steps));
320    }
321}
322
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324enum RangeSliderHandle {
325    Start,
326    End,
327}
328
329fn notify_on_change(new_value: Option<f32>, args: &SliderArgs) {
330    if let Some(v) = new_value
331        && (v - args.value).abs() > f32::EPSILON
332    {
333        args.on_change.call(v);
334    }
335}
336
337pub(super) fn apply_slider_semantics(
338    accessibility: &mut AccessibilityNode,
339    action_handler: &mut Option<AccessibilityActionHandler>,
340    args: &SliderArgs,
341    current_value: f32,
342    on_change: &CallbackWith<f32>,
343) {
344    accessibility.role = Some(Role::Slider);
345    accessibility.orientation = Some(args.axis.accessibility_orientation());
346    accessibility.label = args.accessibility_label.clone();
347    accessibility.description = args.accessibility_description.clone();
348    accessibility.numeric_value = Some(current_value as f64);
349    accessibility.value = args.value_label.clone().or_else(|| {
350        (args.value_indicator != super::SliderValueIndicatorVisibility::Hidden)
351            .then(|| format!("{}%", super::default_value_label(current_value)))
352    });
353    accessibility.min_numeric_value = Some(0.0);
354    accessibility.max_numeric_value = Some(1.0);
355    accessibility.focusable = !args.disabled;
356    accessibility.disabled = args.disabled;
357    accessibility.actions.clear();
358
359    if args.disabled {
360        *action_handler = None;
361        return;
362    }
363
364    let on_change = *on_change;
365    let steps = args.steps;
366    *action_handler = Some(Box::new(move |action| {
367        let delta = if steps == 0 {
368            ACCESSIBILITY_STEP
369        } else {
370            1.0 / (steps as f32 + 1.0)
371        };
372        let new_value = match action {
373            Action::Increment => Some(snap_fraction(current_value + delta, steps)),
374            Action::Decrement => Some(snap_fraction(current_value - delta, steps)),
375            _ => None,
376        };
377
378        if let Some(new_value) = new_value
379            && (new_value - current_value).abs() > f32::EPSILON
380        {
381            on_change.call(new_value);
382        }
383    }));
384    accessibility.actions.push(Action::Increment);
385    accessibility.actions.push(Action::Decrement);
386}
387
388/// Controller for the `range_slider` component.
389pub struct RangeSliderController {
390    pub(crate) is_hovered: bool,
391    pub(crate) is_dragging_start: bool,
392    pub(crate) is_dragging_end: bool,
393    active_handle: Option<RangeSliderHandle>,
394    pub(crate) focus_start: FocusRequester,
395    pub(crate) focus_end: FocusRequester,
396}
397
398impl Default for RangeSliderController {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404impl RangeSliderController {
405    /// Creates a new range slider controller.
406    pub fn new() -> Self {
407        Self {
408            is_hovered: false,
409            is_dragging_start: false,
410            is_dragging_end: false,
411            active_handle: None,
412            focus_start: FocusRequester::new(),
413            focus_end: FocusRequester::new(),
414        }
415    }
416}
417
418#[derive(Clone, Copy)]
419pub(super) struct RangeSliderHandleWidths {
420    pub start: Px,
421    pub end: Px,
422}
423
424pub(super) fn handle_range_slider_state(
425    input: &mut PointerInput,
426    tap_recognizer: State<TapRecognizer>,
427    drag_recognizer: State<DragRecognizer>,
428    state: &State<RangeSliderController>,
429    args: &super::RangeSliderArgs,
430    layout: &SliderLayout,
431    handle_widths: RangeSliderHandleWidths,
432) {
433    if args.disabled {
434        let should_reset = state
435            .with(|inner| inner.is_hovered || inner.is_dragging_start || inner.is_dragging_end);
436        if should_reset {
437            state.with_mut(|inner| {
438                inner.is_hovered = false;
439                inner.is_dragging_start = false;
440                inner.is_dragging_end = false;
441                inner.active_handle = None;
442            });
443        }
444        return;
445    }
446
447    let is_in_component = cursor_within_bounds(input.cursor_position_rel, &input.computed_data);
448
449    let hover_changed = state.with(|inner| inner.is_hovered != is_in_component);
450    if hover_changed {
451        state.with_mut(|inner| {
452            inner.is_hovered = is_in_component;
453        });
454    }
455
456    let tap_result = tap_recognizer.with_mut(|recognizer| {
457        recognizer.update(
458            input.pass,
459            input.pointer_changes.as_mut_slice(),
460            is_in_component,
461        )
462    });
463    let drag_result = drag_recognizer.with_mut(|recognizer| {
464        recognizer.update(
465            input.pass,
466            input.pointer_changes.as_mut_slice(),
467            is_in_component,
468        )
469    });
470
471    let mut new_start: Option<f32> = None;
472    let mut new_end: Option<f32> = None;
473
474    if tap_result.pressed
475        && let Some(progress) = range_cursor_progress(
476            input.cursor_position_rel,
477            layout,
478            handle_widths.start,
479            handle_widths.end,
480        )
481    {
482        let progress = snap_fraction(progress, args.steps);
483        let active_handle = choose_range_slider_handle(
484            input.cursor_position_rel,
485            layout,
486            args.value.0.clamp(0.0, 1.0),
487            args.value.1.clamp(args.value.0.clamp(0.0, 1.0), 1.0),
488            handle_widths.start,
489            handle_widths.end,
490        );
491        state.with_mut(|inner| {
492            inner.active_handle = Some(active_handle);
493            match active_handle {
494                RangeSliderHandle::Start => inner.focus_start.request_focus(),
495                RangeSliderHandle::End => inner.focus_end.request_focus(),
496            }
497        });
498        match active_handle {
499            RangeSliderHandle::Start => new_start = Some(progress.min(args.value.1)),
500            RangeSliderHandle::End => new_end = Some(progress.max(args.value.0)),
501        }
502    }
503
504    if drag_result.started {
505        state.with_mut(|inner| match inner.active_handle {
506            Some(RangeSliderHandle::Start) => inner.is_dragging_start = true,
507            Some(RangeSliderHandle::End) => inner.is_dragging_end = true,
508            None => {}
509        });
510    }
511
512    if (drag_result.updated || state.with(|s| s.is_dragging_start || s.is_dragging_end))
513        && let Some(progress) = range_cursor_progress(
514            input.cursor_position_rel,
515            layout,
516            handle_widths.start,
517            handle_widths.end,
518        )
519    {
520        let progress = snap_fraction(progress, args.steps);
521        state.with(|s| {
522            if s.is_dragging_start {
523                new_start = Some(progress.min(args.value.1)); // Don't cross end
524            } else if s.is_dragging_end {
525                new_end = Some(progress.max(args.value.0)); // Don't cross start
526            }
527        });
528    }
529
530    if tap_result.released || drag_result.ended {
531        state.with_mut(|inner| {
532            inner.is_dragging_start = false;
533            inner.is_dragging_end = false;
534            inner.active_handle = None;
535        });
536    }
537
538    if let Some(ns) = new_start
539        && (ns - args.value.0).abs() > f32::EPSILON
540    {
541        args.on_change.call((ns, args.value.1));
542    }
543    if let Some(ne) = new_end
544        && (ne - args.value.1).abs() > f32::EPSILON
545    {
546        args.on_change.call((args.value.0, ne));
547    }
548}
549
550fn choose_range_slider_handle(
551    cursor_pos: Option<PxPosition>,
552    layout: &SliderLayout,
553    start_value: f32,
554    end_value: f32,
555    start_handle_width: Px,
556    end_handle_width: Px,
557) -> RangeSliderHandle {
558    let cursor_x = cursor_pos.map(|pos| layout.axis.input_main(pos, layout.component_width));
559    let start_center_x =
560        range_handle_center_x(layout, start_value, start_handle_width, end_handle_width);
561    let end_center_x =
562        range_handle_center_x(layout, end_value, start_handle_width, end_handle_width);
563    let dist_start = cursor_x.map(|x| (x - start_center_x).abs());
564    let dist_end = cursor_x.map(|x| (x - end_center_x).abs());
565    if dist_start.unwrap_or(f32::INFINITY) <= dist_end.unwrap_or(f32::INFINITY) {
566        RangeSliderHandle::Start
567    } else {
568        RangeSliderHandle::End
569    }
570}
571
572pub(super) fn apply_range_slider_semantics(
573    accessibility: &mut AccessibilityNode,
574    args: &super::RangeSliderArgs,
575    _current_start: f32,
576    _current_end: f32,
577    _on_change: &CallbackWith<(f32, f32)>,
578) {
579    accessibility.role = Some(Role::Group);
580    accessibility.label = args.accessibility_label.clone();
581    accessibility.description = args.accessibility_description.clone();
582    accessibility.hidden = false;
583    accessibility.focusable = false;
584    accessibility.merge_descendants = true;
585    accessibility.disabled = args.disabled;
586}
587
588#[cfg(test)]
589mod tests {
590    use std::sync::Arc;
591
592    use parking_lot::Mutex;
593
594    use super::*;
595
596    #[test]
597    fn snapped_values_match_tick_fractions_and_preserve_exact_endpoints() {
598        for (value, steps, expected) in [
599            (1.0, 40, 1.0),
600            (0.0, 40, 0.0),
601            (0.87, 6, 0.85714287),
602            (0.72, 6, 0.71428573),
603            (0.34, 2, 0.33333334),
604            (0.66, 2, 0.6666667),
605            (0.625, 3, 0.75),
606            (f32::NAN, 0, 0.0),
607            (f32::INFINITY, 6, 1.0),
608            (f32::NEG_INFINITY, 6, 0.0),
609        ] {
610            assert_eq!(snap_fraction(value, steps), expected);
611        }
612    }
613
614    #[test]
615    fn continuous_and_discrete_keys_follow_their_step_and_page_sizes() {
616        for (key, expected) in [
617            (NamedKey::ArrowRight, 0.51),
618            (NamedKey::ArrowUp, 0.51),
619            (NamedKey::ArrowLeft, 0.49),
620            (NamedKey::ArrowDown, 0.49),
621            (NamedKey::Home, 0.0),
622            (NamedKey::End, 1.0),
623            (NamedKey::PageUp, 0.6),
624            (NamedKey::PageDown, 0.4),
625        ] {
626            assert!(
627                (key_target(
628                    SliderAxis::default(),
629                    &Key::Named(key),
630                    0.5,
631                    0.0,
632                    1.0,
633                    0,
634                    false
635                )
636                .unwrap()
637                    - expected)
638                    .abs()
639                    < 0.000001
640            );
641        }
642        for (steps, next, page) in [(3, 0.75, 0.75), (19, 0.55, 0.6), (199, 0.505, 0.55)] {
643            assert!(
644                (key_target(
645                    SliderAxis::default(),
646                    &Key::Named(NamedKey::ArrowRight),
647                    0.5,
648                    0.0,
649                    1.0,
650                    steps,
651                    false
652                )
653                .unwrap()
654                    - next)
655                    .abs()
656                    < 0.000001
657            );
658            assert!(
659                (key_target(
660                    SliderAxis::default(),
661                    &Key::Named(NamedKey::PageUp),
662                    0.5,
663                    0.0,
664                    1.0,
665                    steps,
666                    false
667                )
668                .unwrap()
669                    - page)
670                    .abs()
671                    < 0.000001
672            );
673        }
674    }
675
676    #[test]
677    fn range_keys_observe_the_neighbor_without_rescaling_the_step() {
678        assert_eq!(
679            key_target(
680                SliderAxis::default(),
681                &Key::Named(NamedKey::Home),
682                0.5,
683                0.2,
684                0.8,
685                0,
686                false
687            ),
688            Some(0.2)
689        );
690        assert_eq!(
691            key_target(
692                SliderAxis::default(),
693                &Key::Named(NamedKey::End),
694                0.5,
695                0.2,
696                0.8,
697                0,
698                false
699            ),
700            Some(0.8)
701        );
702        assert!(
703            (key_target(
704                SliderAxis::default(),
705                &Key::Named(NamedKey::ArrowRight),
706                0.5,
707                0.2,
708                0.8,
709                0,
710                false
711            )
712            .unwrap()
713                - 0.51)
714                .abs()
715                < 0.000001
716        );
717        assert_eq!(
718            key_target(
719                SliderAxis::default(),
720                &Key::Named(NamedKey::ArrowRight),
721                0.75,
722                0.0,
723                0.8,
724                3,
725                false
726            ),
727            Some(0.8)
728        );
729        assert_eq!(
730            key_target(
731                SliderAxis::default(),
732                &Key::Named(NamedKey::ArrowLeft),
733                0.25,
734                0.2,
735                1.0,
736                3,
737                false
738            ),
739            Some(0.2)
740        );
741    }
742
743    #[test]
744    fn vertical_keys_follow_screen_direction_without_consuming_horizontal_arrows() {
745        for reversed in [false, true] {
746            let axis = SliderAxis {
747                orientation: SliderOrientation::Vertical,
748                reversed,
749            };
750            for (key, expected) in [
751                (NamedKey::ArrowUp, if reversed { 0.75 } else { 0.25 }),
752                (NamedKey::ArrowDown, if reversed { 0.25 } else { 0.75 }),
753                (NamedKey::PageUp, if reversed { 0.75 } else { 0.25 }),
754                (NamedKey::PageDown, if reversed { 0.25 } else { 0.75 }),
755                (NamedKey::Home, 0.0),
756                (NamedKey::End, 1.0),
757            ] {
758                assert_eq!(
759                    key_target(axis, &Key::Named(key), 0.5, 0.0, 1.0, 3, false),
760                    Some(expected)
761                );
762            }
763            for key in [NamedKey::ArrowLeft, NamedKey::ArrowRight, NamedKey::Tab] {
764                assert_eq!(
765                    key_target(axis, &Key::Named(key), 0.5, 0.0, 1.0, 3, false),
766                    None
767                );
768            }
769        }
770        let reversed = SliderAxis {
771            reversed: true,
772            ..Default::default()
773        };
774        assert_eq!(
775            key_target(
776                reversed,
777                &Key::Named(NamedKey::ArrowRight),
778                0.5,
779                0.0,
780                1.0,
781                3,
782                false
783            ),
784            Some(0.25)
785        );
786        assert_eq!(
787            key_target(
788                reversed,
789                &Key::Named(NamedKey::ArrowLeft),
790                0.5,
791                0.0,
792                1.0,
793                3,
794                false
795            ),
796            Some(0.75)
797        );
798    }
799
800    #[tessera_ui::tessera]
801    fn key_dispatch_fixture() {
802        let changes = Arc::new(Mutex::new(Vec::new()));
803        let recorded = changes.clone();
804        let value = tessera_ui::remember(|| 0.5);
805        let mut node = SliderKeyboardModifierNode {
806            axis: SliderAxis::default(),
807            model: SliderKeyboardModel::Single(value),
808            steps: 0,
809            disabled: false,
810            space_held: tessera_ui::remember(|| false),
811            on_change: CallbackWith::new(move |value| recorded.lock().push(value)),
812        };
813        assert!(node.handle_key(
814            &Key::Named(NamedKey::ArrowRight),
815            ElementState::Released,
816            ModifiersState::empty()
817        ));
818        assert!(changes.lock().is_empty());
819        for modifiers in [
820            ModifiersState::CONTROL,
821            ModifiersState::ALT,
822            ModifiersState::SUPER,
823        ] {
824            assert!(!node.handle_key(&Key::Named(NamedKey::End), ElementState::Pressed, modifiers));
825        }
826        for key in [Key::Named(NamedKey::Tab), Key::Character("q".into())] {
827            assert!(!node.handle_key(&key, ElementState::Pressed, ModifiersState::empty()));
828        }
829        for _ in 0..3 {
830            assert!(node.handle_key(
831                &Key::Named(NamedKey::ArrowRight),
832                ElementState::Pressed,
833                ModifiersState::empty()
834            ));
835        }
836        assert_eq!(changes.lock().len(), 3);
837        assert!((value.get() - 0.53).abs() < 0.000001);
838        let space = Key::Named(NamedKey::Space);
839        assert!(node.handle_key(&space, ElementState::Pressed, ModifiersState::empty()));
840        assert!(node.handle_key(
841            &Key::Named(NamedKey::ArrowRight),
842            ElementState::Pressed,
843            ModifiersState::empty()
844        ));
845        assert!((value.get() - 0.63).abs() < 0.000001);
846        assert!(node.handle_key(&space, ElementState::Released, ModifiersState::empty()));
847        assert!(node.handle_key(
848            &Key::Named(NamedKey::ArrowLeft),
849            ElementState::Pressed,
850            ModifiersState::empty()
851        ));
852        assert!((value.get() - 0.62).abs() < 0.000001);
853        assert_eq!(changes.lock().len(), 5);
854        node.handle_key(
855            &Key::Named(NamedKey::End),
856            ElementState::Pressed,
857            ModifiersState::empty(),
858        );
859        assert!(node.handle_key(
860            &Key::Named(NamedKey::ArrowRight),
861            ElementState::Pressed,
862            ModifiersState::empty()
863        ));
864        assert_eq!(changes.lock().len(), 6);
865        node.disabled = true;
866        assert!(!node.handle_key(
867            &Key::Named(NamedKey::Home),
868            ElementState::Pressed,
869            ModifiersState::empty()
870        ));
871        assert_eq!(value.get(), 1.0);
872        let range = tessera_ui::remember(|| (0.2, 0.8));
873        let start = SliderKeyboardModifierNode {
874            axis: SliderAxis::default(),
875            model: SliderKeyboardModel::Start(range),
876            steps: 0,
877            disabled: false,
878            space_held: tessera_ui::remember(|| false),
879            on_change: CallbackWith::new(move |_| recorded_range(range)),
880        };
881        let end = SliderKeyboardModifierNode {
882            axis: SliderAxis::default(),
883            model: SliderKeyboardModel::End(range),
884            steps: 0,
885            disabled: false,
886            space_held: tessera_ui::remember(|| false),
887            on_change: CallbackWith::new(move |_| recorded_range(range)),
888        };
889        for key in [
890            NamedKey::ArrowRight,
891            NamedKey::ArrowRight,
892            NamedKey::ArrowRight,
893        ] {
894            assert!(start.handle_key(
895                &Key::Named(key),
896                ElementState::Pressed,
897                ModifiersState::empty(),
898            ));
899        }
900        assert!((range.get().0 - 0.23).abs() < 0.000001);
901        end.handle_key(
902            &Key::Named(NamedKey::Home),
903            ElementState::Pressed,
904            ModifiersState::empty(),
905        );
906        assert_eq!(range.get().0, range.get().1);
907        start.handle_key(
908            &Key::Named(NamedKey::End),
909            ElementState::Pressed,
910            ModifiersState::empty(),
911        );
912        assert_eq!(range.get().0, range.get().1);
913        end.handle_key(
914            &Key::Named(NamedKey::End),
915            ElementState::Pressed,
916            ModifiersState::empty(),
917        );
918        start.handle_key(
919            &Key::Named(NamedKey::End),
920            ElementState::Pressed,
921            ModifiersState::empty(),
922        );
923        assert_eq!(range.get(), (1.0, 1.0));
924        crate::spacer::spacer();
925    }
926
927    fn recorded_range(range: State<(f32, f32)>) {
928        let (start, end) = range.get();
929        assert!(start <= end);
930    }
931
932    #[test]
933    fn key_dispatch_consumes_limits_and_releases_but_preserves_shortcuts_and_tab() {
934        tessera_ui::testing::layout_test(|| {
935            key_dispatch_fixture();
936        })
937        .run();
938    }
939}