1use std::rc::Rc;
41
42use frust_core::accesskit::Role;
43use frust_core::{
44 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, Key, KeyEvent,
45 LayoutCtx, Modifiers, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
46};
47use frust_theme::{DesignLanguage, GlassMaterial, GlassScale, Theme};
48use kurbo::{Point, Size};
49use peniko::{Brush, Color, Gradient};
50
51use crate::authoring::presses;
52use crate::shortcuts::press_claims_focus;
53
54const KEY_STEP: f64 = 0.05;
56const HEIGHT: f64 = 24.0;
58const DEFAULT_WIDTH: f64 = 200.0;
60const TRACK_H: f64 = 4.0;
64const THUMB: f64 = 18.0;
67const CUPERTINO_THUMB: f64 = 28.0;
75const CUPERTINO_THUMB_FILL: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
81const SPECULAR_PEAK_SCALE: f32 = 1.8;
85const TRACK: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
88const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
91const THUMB_FILL: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
93
94fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
100 match theme {
101 Some(theme) => {
102 let scheme = theme.scheme();
103 (
104 scheme.surface_container_highest,
105 scheme.primary,
106 scheme.primary,
107 )
108 }
109 None => (TRACK, FILL, THUMB_FILL),
110 }
111}
112
113fn is_cupertino(theme: Option<&Theme>) -> bool {
118 theme.map(|t| t.design_language) == Some(DesignLanguage::Cupertino)
119}
120
121fn resolve_control_glass(theme: Option<&Theme>) -> GlassMaterial {
132 theme
133 .map(|t| t.glass.control.clone())
134 .unwrap_or_else(|| GlassScale::opaque_material().control)
135}
136
137fn with_alpha(color: Color, alpha: f32) -> Color {
140 let c = color.components;
141 Color::new([c[0], c[1], c[2], alpha])
142}
143
144type OnChange<State> = Rc<dyn Fn(&mut State, f64)>;
146
147pub struct SliderView<State: 'static> {
149 value: f64,
150 on_change: OnChange<State>,
151}
152
153pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
156 value: f64,
157 on_change: F,
158) -> SliderView<State> {
159 SliderView {
160 value: value.clamp(0.0, 1.0),
161 on_change: Rc::new(on_change),
162 }
163}
164
165#[allow(non_snake_case)]
167pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
168 value: f64,
169 on_change: F,
170) -> SliderView<State> {
171 slider(value, on_change)
172}
173
174pub struct SliderWidget {
176 value: f64,
177 captured: bool,
181 on_change: crate::authoring::ErasedArgCallback<f64>,
182}
183
184fn value_from_key(key: &KeyEvent, value: f64) -> Option<f64> {
186 if key.modifiers != Modifiers::default() {
187 return None;
188 }
189 let Key::Named(named) = &key.key else {
190 return None;
191 };
192 let next = match named {
193 NamedKey::ArrowLeft | NamedKey::ArrowDown => value - KEY_STEP,
194 NamedKey::ArrowRight | NamedKey::ArrowUp => value + KEY_STEP,
195 NamedKey::Home => 0.0,
196 NamedKey::End => 1.0,
197 _ => return None,
198 };
199 Some(next.clamp(0.0, 1.0))
200}
201
202fn value_from_x(x: f64, width: f64) -> f64 {
204 if width <= 0.0 {
205 0.0
206 } else {
207 (x / width).clamp(0.0, 1.0)
208 }
209}
210
211impl<State: 'static> View<State> for SliderView<State> {
212 type Element = SliderWidget;
213
214 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
215 SliderWidget {
216 value: self.value,
217 captured: false,
218 on_change: crate::authoring::erase_callback_arg(&self.on_change),
219 }
220 }
221
222 fn rebuild(
223 &self,
224 prev: &Self,
225 element: &mut SliderWidget,
226 _ctx: &mut BuildCtx<'_>,
227 ) -> ChangeFlags {
228 element.on_change = crate::authoring::erase_callback_arg(&self.on_change);
229 if prev.value != self.value {
230 element.value = self.value;
231 ChangeFlags::PAINT
232 } else {
233 ChangeFlags::NONE
234 }
235 }
236}
237
238impl Widget for SliderWidget {
239 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
240 let width = if bc.max().width.is_finite() {
241 bc.max().width
242 } else {
243 DEFAULT_WIDTH
244 };
245 bc.constrain(Size::new(width, HEIGHT))
246 }
247
248 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
249 let theme = Theme::from_paint_ctx(ctx);
250 let (track, fill, thumb_fill) = resolve_colors(theme);
251 let o = ctx.origin();
252 let w = ctx.size().width;
253 let mid_y = o.y + ctx.size().height / 2.0;
254 scene.fill_rounded_rect(
256 Point::new(o.x, mid_y - TRACK_H / 2.0),
257 Size::new(w, TRACK_H),
258 TRACK_H / 2.0,
259 track,
260 );
261 let thumb_x = o.x + self.value.clamp(0.0, 1.0) * w;
263 scene.fill_rounded_rect(
264 Point::new(o.x, mid_y - TRACK_H / 2.0),
265 Size::new((thumb_x - o.x).max(0.0), TRACK_H),
266 TRACK_H / 2.0,
267 fill,
268 );
269
270 if is_cupertino(theme) {
271 let diam = CUPERTINO_THUMB;
274 let thumb_origin = Point::new(thumb_x - diam / 2.0, mid_y - diam / 2.0);
275 let thumb_size = Size::new(diam, diam);
276 let glass_control = resolve_control_glass(theme);
277 scene.draw_shadow(
278 Point::new(
279 thumb_origin.x,
280 thumb_origin.y + glass_control.shadow.y_offset,
281 ),
282 thumb_size,
283 diam / 2.0,
284 glass_control.shadow.blur_std_dev,
285 with_alpha(Color::BLACK, glass_control.shadow.color_alpha),
286 );
287 scene.fill_rounded_rect(thumb_origin, thumb_size, diam / 2.0, CUPERTINO_THUMB_FILL);
288 let highlight_center = Point::new(thumb_x - diam * 0.18, mid_y - diam * 0.28);
289 let peak_alpha = (glass_control.hairline_alpha * SPECULAR_PEAK_SCALE).min(1.0);
290 let highlight = Gradient::new_radial(highlight_center, (diam * 0.65) as f32)
291 .with_stops([
292 (0.0f32, with_alpha(CUPERTINO_THUMB_FILL, peak_alpha)),
293 (1.0f32, with_alpha(CUPERTINO_THUMB_FILL, 0.0)),
294 ]);
295 scene.fill_rounded_rect_brush(
296 thumb_origin,
297 thumb_size,
298 diam / 2.0,
299 &Brush::Gradient(highlight),
300 );
301 } else {
302 scene.fill_rounded_rect(
305 Point::new(thumb_x - THUMB / 2.0, mid_y - THUMB / 2.0),
306 Size::new(THUMB, THUMB),
307 THUMB / 2.0,
308 thumb_fill,
309 );
310 }
311 }
312
313 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
314 if let InputEvent::Key(key) = event {
315 let Some(v) = value_from_key(key, self.value).filter(|_| ctx.has_focus()) else {
316 return EventResult::Ignored;
317 };
318 if v != self.value {
320 (self.on_change)(ctx, v);
321 ctx.request_redraw();
322 }
323 return EventResult::Handled;
324 }
325 let InputEvent::Pointer(p) = event else {
326 return EventResult::Ignored;
327 };
328 match p.phase {
329 PointerPhase::Down => {
330 if !presses(p) {
331 return EventResult::Ignored;
332 }
333 if press_claims_focus(ctx) {
334 ctx.request_focus();
335 }
336 self.captured = true;
337 ctx.capture_pointer();
338 let v = value_from_x(p.position.x, ctx.size().width);
339 (self.on_change)(ctx, v);
340 ctx.request_redraw();
341 EventResult::Handled
342 }
343 PointerPhase::Move => {
344 if !self.captured {
346 return EventResult::Ignored;
347 }
348 let v = value_from_x(p.position.x, ctx.size().width);
349 (self.on_change)(ctx, v);
350 ctx.request_redraw();
351 EventResult::Handled
352 }
353 PointerPhase::Up | PointerPhase::Cancel => {
354 if !self.captured {
355 return EventResult::Ignored;
356 }
357 self.captured = false;
358 ctx.request_redraw();
359 EventResult::Handled
360 }
361 }
362 }
363
364 fn semantics(&self, ctx: &mut SemanticsCtx) {
365 ctx.push_node(Role::Slider, |node| {
367 node.set_numeric_value(self.value);
368 node.set_min_numeric_value(0.0);
369 node.set_max_numeric_value(1.0);
370 });
371 }
372
373 fn is_focusable(&self) -> bool {
375 true
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::any::Any;
383
384 #[derive(Default)]
385 struct Val {
386 value: f64,
387 changes: u32,
388 }
389
390 fn widget(value: f64) -> SliderWidget {
391 let view = slider::<Val, _>(value, |s: &mut Val, v: f64| {
392 s.value = v;
393 s.changes += 1;
394 });
395 let mut counter = 0u64;
396 View::<Val>::build(&view, &mut BuildCtx::new(&mut counter))
397 }
398
399 fn ev(phase: PointerPhase, x: f64) -> InputEvent {
400 InputEvent::Pointer(frust_core::PointerEvent {
401 phase,
402 position: Point::new(x, 12.0),
403 button: frust_core::PointerButton::Primary,
404 })
405 }
406
407 fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
409 InputEvent::Pointer(frust_core::PointerEvent {
410 phase,
411 position: Point::new(x, 12.0),
412 button: frust_core::PointerButton::Secondary,
413 })
414 }
415
416 fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) {
417 let state_any: &mut dyn Any = state;
418 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
419 w.event(&mut ctx, event);
420 }
421
422 #[test]
423 fn a_secondary_press_never_captures_or_reports_a_value() {
424 let mut w = widget(0.0);
425 let mut state = Val::default();
426 dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
427 assert!(!w.captured, "no capture opened");
428 assert_eq!(state.changes, 0, "and no value reported");
429 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
431 assert_eq!(state.changes, 0);
432
433 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
435 assert!(w.captured);
436 assert_eq!(state.value, 0.25);
437 }
438
439 #[test]
440 fn value_math_maps_position_to_fraction() {
441 assert_eq!(value_from_x(0.0, 200.0), 0.0);
442 assert_eq!(value_from_x(100.0, 200.0), 0.5);
443 assert_eq!(value_from_x(200.0, 200.0), 1.0);
444 assert_eq!(value_from_x(-40.0, 200.0), 0.0);
446 assert_eq!(value_from_x(9000.0, 200.0), 1.0);
447 assert_eq!(value_from_x(10.0, 0.0), 0.0);
449 }
450
451 #[test]
452 fn down_reports_value_at_position() {
453 let mut w = widget(0.0);
454 let mut state = Val::default();
455 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
456 assert_eq!(state.value, 0.25);
457 assert_eq!(state.changes, 1);
458 }
459
460 #[test]
461 fn drag_reports_continuously_and_clamps() {
462 let mut w = widget(0.0);
463 let mut state = Val::default();
464 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
465 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 300.0)); assert_eq!(state.value, 1.0);
467 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, -20.0)); assert_eq!(state.value, 0.0);
469 assert_eq!(state.changes, 3);
470 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 0.0));
472 assert_eq!(state.changes, 3);
473 }
474
475 #[test]
476 fn hover_move_without_down_is_ignored_noop() {
477 let mut w = widget(0.3);
478 let mut state = Val::default();
479 let state_any: &mut dyn Any = &mut state;
480 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
481 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
482 assert!(matches!(result, EventResult::Ignored));
483 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
484 assert_eq!(state.changes, 0, "hover must not report a value");
485 }
486
487 #[test]
488 fn cancel_clears_captured_state() {
489 let mut w = widget(0.0);
490 let mut state = Val::default();
491 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
492 assert!(w.captured);
493 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
494 assert!(!w.captured, "Cancel disarms the drag");
495 let before = state.changes;
497 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
498 assert_eq!(state.changes, before, "hover after Cancel does not report");
499 }
500
501 #[derive(Default)]
506 struct TrackRecorder {
507 rrects: Vec<Color>,
508 shadows: Vec<(Point, Size, f64, f64, Color)>,
509 brushes: Vec<(Point, Size, f64, Brush)>,
510 }
511
512 impl PaintScene for TrackRecorder {
513 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
514 fn draw_text(&mut self, _o: Point, _t: &str) {}
515 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
516 self.rrects.push(color);
517 }
518 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
519 self.shadows.push((o, s, radius, std_dev, color));
520 }
521 fn fill_rounded_rect_brush(&mut self, o: Point, s: Size, radius: f64, brush: &Brush) {
522 self.brushes.push((o, s, radius, brush.clone()));
523 }
524 }
525
526 fn paint_colors(value: f64, theme: Option<&Theme>) -> Vec<Color> {
527 let mut w = widget(value);
528 let mut rec = TrackRecorder::default();
529 let mut ctx = match theme {
530 Some(t) => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(t),
531 None => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)),
532 };
533 w.paint(&mut ctx, &mut rec);
534 rec.rrects
535 }
536
537 #[test]
538 fn unthemed_paint_uses_fallback_constants() {
539 assert_eq!(paint_colors(0.5, None), vec![TRACK, FILL, THUMB_FILL]);
540 }
541
542 #[test]
543 fn themed_paint_resolves_the_scheme_s_slider_roles() {
544 let theme = Theme::neutral();
545 let scheme = theme.scheme();
546 assert_eq!(
547 paint_colors(0.5, Some(&theme)),
548 vec![
549 scheme.surface_container_highest,
550 scheme.primary,
551 scheme.primary
552 ],
553 );
554 }
555
556 #[test]
557 fn non_cupertino_and_unthemed_paint_have_no_reflective_treatment() {
558 let mut unthemed = widget(0.5);
562 let mut rec = TrackRecorder::default();
563 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT));
564 unthemed.paint(&mut ctx, &mut rec);
565 assert_eq!(rec.rrects.len(), 3, "track + fill + thumb, nothing else");
566 assert!(rec.shadows.is_empty());
567 assert!(rec.brushes.is_empty());
568
569 let theme = Theme::neutral();
570 assert_ne!(
571 theme.design_language,
572 DesignLanguage::Cupertino,
573 "the default path under test must not be the Cupertino one"
574 );
575 let mut themed = widget(0.5);
576 let mut rec = TrackRecorder::default();
577 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
578 themed.paint(&mut ctx, &mut rec);
579 assert_eq!(rec.rrects.len(), 3);
580 assert!(rec.shadows.is_empty());
581 assert!(rec.brushes.is_empty());
582 }
583
584 fn cupertino_theme() -> Theme {
591 Theme::builder(Theme::neutral())
592 .design_language(DesignLanguage::Cupertino)
593 .map_glass(|mut g| {
594 g.control = GlassMaterial {
595 blur_radius_intent: 15.0,
596 fills_light: Vec::new(),
597 fills_dark: Vec::new(),
598 hairline_alpha: 0.3,
599 shadow: frust_theme::ShadowSpec {
600 y_offset: 2.0,
601 blur_std_dev: 4.0,
602 color_alpha: 0.12,
603 },
604 };
605 g
606 })
607 .build()
608 }
609
610 #[test]
611 fn cupertino_themed_paint_uses_the_larger_reflective_knob() {
612 let theme = cupertino_theme();
613 assert_eq!(theme.design_language, DesignLanguage::Cupertino);
614 let mut w = widget(0.5);
615 let mut rec = TrackRecorder::default();
616 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
617 w.paint(&mut ctx, &mut rec);
618
619 assert_eq!(rec.rrects.len(), 3);
622 assert_eq!(rec.rrects[2], CUPERTINO_THUMB_FILL);
623
624 assert_eq!(rec.shadows.len(), 1, "one drop shadow under the knob");
625 let expected = theme.glass.control.shadow;
626 assert_eq!(
627 rec.shadows[0].2,
628 CUPERTINO_THUMB / 2.0,
629 "circular, knob radius"
630 );
631 assert_eq!(rec.shadows[0].3, expected.blur_std_dev);
632
633 assert_eq!(rec.brushes.len(), 1, "one specular gradient highlight");
634 assert_eq!(
635 rec.brushes[0].2,
636 CUPERTINO_THUMB / 2.0,
637 "circular, knob radius"
638 );
639 match &rec.brushes[0].3 {
640 Brush::Gradient(g) => assert_eq!(g.stops.len(), 2, "peak + fade-to-transparent"),
641 _ => panic!("expected a gradient brush for the specular highlight"),
642 }
643 }
644
645 #[test]
646 fn rebuild_adopts_new_value_without_self_mutation() {
647 let mut counter = 0u64;
648 let prev = slider::<Val, _>(0.2, |_s, _v| {});
649 let mut w = View::<Val>::build(&prev, &mut BuildCtx::new(&mut counter));
650 assert_eq!(w.value, 0.2);
651 let next = slider::<Val, _>(0.8, |_s, _v| {});
652 View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
653 assert_eq!(w.value, 0.8);
654 }
655
656 mod keyboard {
657 use super::*;
658 use frust_core::{DiscardScene, FrameTime, RenderRoot};
659
660 fn key(named: NamedKey, repeat: bool) -> InputEvent {
661 InputEvent::Key(KeyEvent {
662 key: Key::Named(named),
663 modifiers: Modifiers::default(),
664 repeat,
665 })
666 }
667
668 #[test]
669 fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
670 let mut logic = |s: &mut Val| {
671 crate::column()
672 .child(slider(0.5, |s: &mut Val, _| s.changes += 1))
673 .child(slider(s.value, |s: &mut Val, _| s.changes += 10))
674 };
675 let mut root: RenderRoot<Val, crate::FlexView<Val>> = RenderRoot::new();
676 let mut state = Val {
677 value: 0.5,
678 changes: 0,
679 };
680 root.rebuild(&mut logic, &mut state);
681 root.layout(Size::new(200.0, 100.0));
682 root.paint(&mut DiscardScene, FrameTime::ZERO);
683 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
684 root.event(&mut state, &ev(PointerPhase::Up, 50.0));
685 assert_eq!(state.changes, 1);
686 root.event(&mut state, &key(NamedKey::Tab, false));
687 root.event(&mut state, &key(NamedKey::ArrowRight, false));
688 assert_eq!(state.changes, 11, "Tab went to the second slider");
689 }
690
691 #[test]
692 fn a_touch_tap_changes_the_value_without_claiming_focus() {
693 let mut logic = |s: &mut Val| {
694 crate::column().child(slider(s.value, |s: &mut Val, _| s.changes += 1))
695 };
696 let mut root: RenderRoot<Val, crate::FlexView<Val>> = RenderRoot::new();
697 let mut state = Val {
698 value: 0.5,
699 changes: 0,
700 };
701 root.rebuild(&mut logic, &mut state);
702 root.layout(Size::new(200.0, 100.0));
703 root.paint(&mut DiscardScene, FrameTime::ZERO);
704 for phase in [PointerPhase::Down, PointerPhase::Up] {
705 let InputEvent::Pointer(event) = ev(phase, 50.0) else {
706 unreachable!()
707 };
708 let contact = InputEvent::PointerContact {
709 pointer_id: frust_core::event::PointerId::touch(0),
710 event,
711 };
712 root.event(&mut state, &contact);
713 }
714 assert_eq!(state.changes, 1);
715 assert!(!root.is_focus_active(), "a tap leaves focus alone");
716 }
717
718 #[test]
719 fn tab_focuses_the_slider_then_arrows_step_it() {
720 assert!(widget(0.5).is_focusable());
721 let mut logic = |s: &mut Val| {
722 crate::column().child(slider(s.value, |s: &mut Val, v: f64| {
723 s.value = v;
724 s.changes += 1;
725 }))
726 };
727 let mut root: RenderRoot<Val, crate::FlexView<Val>> = RenderRoot::new();
728 let mut state = Val {
729 value: 0.5,
730 changes: 0,
731 };
732 let mut frame = |root: &mut RenderRoot<Val, _>, state: &mut Val| {
733 root.rebuild(&mut logic, state);
734 root.layout(Size::new(200.0, 100.0));
735 root.paint(&mut DiscardScene, FrameTime::ZERO);
736 };
737 frame(&mut root, &mut state);
738 root.event(&mut state, &key(NamedKey::ArrowRight, false));
739 assert_eq!(state.changes, 0, "an unfocused slider takes no keys");
740
741 root.event(&mut state, &key(NamedKey::Tab, false));
742 assert!(root.is_focus_active());
743 assert!(
744 root.event(&mut state, &key(NamedKey::ArrowRight, false))
745 .handled
746 );
747 assert!((state.value - 0.55).abs() < 1e-9);
748 frame(&mut root, &mut state);
749 root.event(&mut state, &key(NamedKey::ArrowDown, true));
750 assert!((state.value - 0.5).abs() < 1e-9, "auto-repeat steps too");
751 frame(&mut root, &mut state);
752 root.event(&mut state, &key(NamedKey::End, false));
753 assert_eq!(state.value, 1.0);
754 frame(&mut root, &mut state);
755 let changes = state.changes;
756 assert!(
757 root.event(&mut state, &key(NamedKey::ArrowUp, false))
758 .handled
759 );
760 assert_eq!(
761 state.changes, changes,
762 "the end of the track reports nothing"
763 );
764 root.event(&mut state, &key(NamedKey::Home, false));
765 assert_eq!(state.value, 0.0);
766 }
767
768 #[test]
769 fn chorded_and_other_keys_are_not_navigation() {
770 let shift = Modifiers {
771 shift: true,
772 ..Modifiers::default()
773 };
774 let chorded = KeyEvent {
775 key: Key::Named(NamedKey::ArrowLeft),
776 modifiers: shift,
777 repeat: false,
778 };
779 assert_eq!(value_from_key(&chorded, 0.5), None);
780 let enter = KeyEvent {
781 key: Key::Named(NamedKey::Enter),
782 modifiers: Modifiers::default(),
783 repeat: false,
784 };
785 assert_eq!(value_from_key(&enter, 0.5), None);
786 let left = KeyEvent {
787 key: Key::Named(NamedKey::ArrowLeft),
788 ..enter
789 };
790 assert_eq!(
791 value_from_key(&left, 0.02),
792 Some(0.0),
793 "clamped at the start"
794 );
795 }
796 }
797}