1use std::cell::Cell;
140use std::rc::Rc;
141
142use teksilo_canvas::{Point, Rect, SizeProposal};
143use teksilo_core::accessibility::AccessNodeBuilder;
144use teksilo_core::binding::BindingLevel;
145use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
146use teksilo_core::focus::FocusOrigin;
147use teksilo_core::gesture::{DragPhase, TapStreak};
148use teksilo_core::pointer::PointerId;
149use teksilo_core::pointer::touch_action::TouchAction;
150use teksilo_core::signal::{Prop, Signal};
151use teksilo_core::styles::{
152 SharedSliderStyle, SharedTooltipStyle, SliderOrientation, SliderStyle, SliderStyleConfig,
153 SliderVariant, TooltipStyle,
154};
155use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
156use teksilo_core::widget_builder::HandlerSet;
157use teksilo_core::widget_id::WidgetId;
158use teksilo_tokens::Orientation;
159
160use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
161
162mod readout;
163
164use readout::{Readout, ReadoutInputs, ValueFormat};
165
166pub use teksilo_core::styles::SliderVariant as SliderVariantExport;
170use teksilo_i18n::LocalizedString;
171
172pub const SLIDER_PART_BODY: u16 = 0;
175pub const SLIDER_PART_THUMB: u16 = 1;
177
178const RESET_ACTION_ID: i32 = 1000;
187
188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum SliderValueTooltipPlacement {
198 #[default]
202 Auto,
203 Above,
206 Below,
209 FollowPointer,
215}
216
217pub struct Slider {
221 value: Signal<f32>,
222 min: f32,
223 max: f32,
224 step: Option<f32>,
225 page_step: Option<f32>,
226 orientation: Orientation,
227 enabled: Prop<bool>,
230 label: Option<LocalizedString>,
232 variant: SliderVariant,
233 tick_count: Option<u32>,
234 style_override: Option<SharedSliderStyle>,
235 on_change: Option<Rc<dyn Fn(f32, &mut teksilo_core::widget::EventContext)>>,
236 hovered: Signal<bool>,
237 dragging: Signal<bool>,
238 focused: Signal<bool>,
242 cached_bounds: Rc<Cell<Rect>>,
243 cached_rtl: Rc<Cell<bool>>,
248 thumb_diameter: Cell<f32>,
251 body_id: Option<WidgetId>,
252 tooltip_text: Option<LocalizedString>,
256 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
258 composite_tooltip_content: Option<Box<dyn Widget>>,
260 value_format: Option<ValueFormat>,
262 value_tooltip_placement: SliderValueTooltipPlacement,
264 value_tooltip_style: Option<SharedTooltipStyle>,
266 default_value: Option<f32>,
268 jump_on_press: bool,
270 effective_enabled: Option<Signal<bool>>,
273}
274
275impl Slider {
276 pub fn new(value: Signal<f32>, min: f32, max: f32) -> Self {
279 Self {
280 value,
281 min,
282 max,
283 step: None,
284 page_step: None,
285 orientation: Orientation::Horizontal,
286 enabled: Prop::Static(true),
287 label: None,
288 variant: SliderVariant::default(),
289 tick_count: None,
290 style_override: None,
291 hovered: Signal::new(false),
292 on_change: None,
293 dragging: Signal::new(false),
294 focused: Signal::new(false),
295 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
296 cached_rtl: Rc::new(Cell::new(false)),
297 thumb_diameter: Cell::new(0.0),
298 body_id: None,
299 tooltip_text: None,
300 rich_tooltip_source: None,
301 composite_tooltip_content: None,
302 value_format: None,
303 value_tooltip_placement: SliderValueTooltipPlacement::Auto,
304 value_tooltip_style: None,
305 default_value: None,
306 jump_on_press: false,
307 effective_enabled: None,
308 }
309 }
310
311 pub fn on_change(
328 mut self,
329 f: impl Fn(f32, &mut teksilo_core::widget::EventContext) + 'static,
330 ) -> Self {
331 self.on_change = Some(Rc::new(f));
332 self
333 }
334
335 pub fn step(mut self, step: f32) -> Self {
339 self.step = Some(step);
340 self
341 }
342
343 pub fn page_step(mut self, page_step: f32) -> Self {
350 self.page_step = Some(page_step);
351 self
352 }
353
354 fn effective_step(&self) -> f32 {
360 self.step.unwrap_or((self.max - self.min) * 0.01)
361 }
362
363 fn effective_page_step(&self) -> f32 {
365 self.page_step.unwrap_or(self.effective_step() * 10.0)
366 }
367
368 pub fn orientation(mut self, orientation: Orientation) -> Self {
371 self.orientation = orientation;
372 self
373 }
374
375 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
379 self.enabled = enabled.into();
380 self
381 }
382
383 pub fn variant(mut self, variant: SliderVariant) -> Self {
390 self.variant = variant;
391 self
392 }
393
394 pub fn tick_count(mut self, count: u32) -> Self {
398 self.tick_count = Some(count);
399 self
400 }
401
402 pub fn style(mut self, style: impl SliderStyle) -> Self {
405 self.style_override = Some(Rc::new(style));
406 self
407 }
408
409 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
413 let ls: LocalizedString = label.into();
414 self.label = Some(ls);
415 self
416 }
417
418 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
426 self.tooltip_text = Some(text.into());
427 self.rich_tooltip_source = None;
428 self.composite_tooltip_content = None;
429 self
430 }
431
432 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
436 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
437 self.tooltip_text = None;
438 self.composite_tooltip_content = None;
439 self
440 }
441
442 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
446 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
447 self.tooltip_text = None;
448 self.composite_tooltip_content = None;
449 self
450 }
451
452 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
456 self.composite_tooltip_content = Some(Box::new(content));
457 self.tooltip_text = None;
458 self.rich_tooltip_source = None;
459 self
460 }
461
462 pub fn value_tooltip(mut self, format: impl Fn(f32) -> LocalizedString + 'static) -> Self {
500 self.value_format = Some(Rc::new(format));
501 self
502 }
503
504 pub fn value_tooltip_placement(mut self, placement: SliderValueTooltipPlacement) -> Self {
509 self.value_tooltip_placement = placement;
510 self
511 }
512
513 pub fn value_tooltip_style(mut self, style: impl TooltipStyle) -> Self {
520 self.value_tooltip_style = Some(Rc::new(style));
521 self
522 }
523
524 pub fn jump_on_press(mut self, on: bool) -> Self {
543 self.jump_on_press = on;
544 self
545 }
546
547 pub fn default_value(mut self, value: f32) -> Self {
564 self.default_value = Some(value);
565 self
566 }
567}
568
569impl std::fmt::Debug for Slider {
570 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571 f.debug_struct("Slider")
572 .field("min", &self.min)
573 .field("max", &self.max)
574 .field("enabled", &self.enabled.get())
575 .field("variant", &self.variant)
576 .finish()
577 }
578}
579
580impl Widget for Slider {
581 fn build(
582 &mut self,
583 ctx: &mut teksilo_core::build_context::BuildContext,
584 ) -> Vec<teksilo_core::widget_id::WidgetId> {
585 let self_id = ctx.self_id();
586 ctx.enabled_when(self_id, self.enabled.clone());
588 let effective_enabled = ctx.effective_enabled_signal(self_id);
589 effective_enabled.bind_to(
591 self_id,
592 ctx.binding_registry(),
593 BindingLevel::AccessibilityOnly,
594 );
595 self.effective_enabled = Some(effective_enabled.clone());
596 self.value.bind_to(
599 self_id,
600 ctx.binding_registry(),
601 BindingLevel::AccessibilityOnly,
602 );
603
604 let style: SharedSliderStyle = self
607 .style_override
608 .clone()
609 .or_else(|| ctx.theme().style_slots.slider.clone())
610 .unwrap_or_else(|| {
611 Rc::new(crate::styles::RecipeSliderStyle::for_tokens(
612 &ctx.theme().input,
613 ))
614 });
615
616 let min = self.min;
619 let max = self.max;
620 let value_normalized = self.value.map(move |v| {
621 let range = max - min;
622 if range <= 0.0 {
623 0.0
624 } else {
625 ((*v - min) / range).clamp(0.0, 1.0)
626 }
627 });
628
629 let orientation = match self.orientation {
630 Orientation::Horizontal => SliderOrientation::Horizontal,
631 Orientation::Vertical => SliderOrientation::Vertical,
632 };
633
634 let cfg = SliderStyleConfig {
635 value_normalized,
636 is_hovered: self.hovered.clone(),
637 is_dragging: self.dragging.clone(),
638 is_disabled: effective_enabled.map(|on| !*on),
639 focus_origin: self.focused.zip(&ctx.focus_visible()).map(|(f, v)| {
644 if !*f {
645 None
646 } else if *v {
647 Some(FocusOrigin::Keyboard)
648 } else {
649 Some(FocusOrigin::POINTER)
650 }
651 }),
652 orientation,
653 tick_count: self.tick_count,
654 variant: self.variant,
655 };
656 let body_id = style.make_body(&cfg, ctx);
657 self.body_id = Some(body_id);
658
659 let thumb_radius = style.thumb_diameter_for(&cfg, &ctx.theme().input) * 0.5;
669 self.thumb_diameter.set(thumb_radius * 2.0);
670
671 let value = self.value.clone();
672 let single_step = self.effective_step();
673 let page_step = self.effective_page_step();
674 let snap_step = self.effective_step();
675 let orientation = self.orientation;
676 let hovered = self.hovered.clone();
677 let dragging = self.dragging.clone();
678 let focused = self.focused.clone();
679 let cached_bounds = self.cached_bounds.clone();
680
681 let report = {
687 let value = value.clone();
688 let on_change = self.on_change.clone();
689 move |before: f32, ctx: &mut teksilo_core::widget::EventContext| {
690 let now = value.get();
691 if now != before
692 && let Some(ref f) = on_change
693 {
694 f(now, ctx);
695 }
696 }
697 };
698
699 let follows_pointer = self.value_format.is_some()
707 && self.value_tooltip_placement == SliderValueTooltipPlacement::FollowPointer;
708 let drag_at: Signal<Option<Point>> = Signal::new(None);
709 if follows_pointer {
710 drag_at.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
711 }
712 let readout = self.value_format.clone().map(|format| {
713 Readout::mount(
714 ctx,
715 ReadoutInputs {
716 value: value.clone(),
717 min,
718 max,
719 orientation,
720 thumb_diameter: thumb_radius * 2.0,
721 bounds: cached_bounds.clone(),
722 hovered: hovered.clone(),
723 dragging: dragging.clone(),
724 focused: focused.clone(),
725 placement: self.value_tooltip_placement,
726 drag_at: drag_at.clone(),
727 style: self.value_tooltip_style.clone(),
728 },
729 format,
730 effective_enabled.clone(),
731 )
732 });
733 let sync_readout = move |ctx: &mut teksilo_core::widget::EventContext| {
734 if let Some(readout) = &readout {
735 readout.sync(ctx);
736 }
737 };
738
739 let reset_to_default = self.default_value.map(|default| {
743 let value = value.clone();
744 let report = report.clone();
745 let sync_readout = sync_readout.clone();
746 move |ctx: &mut teksilo_core::widget::EventContext| {
747 let before = value.get();
748 value.set(default.clamp(min, max));
749 report(before, ctx);
750 sync_readout(ctx);
751 }
752 });
753 let taps = Rc::new(Cell::new(TapStreak::EMPTY));
755
756 let adjust_by_step = {
757 let value = value.clone();
758 move |positive: bool, page: bool| {
759 let s = if page { page_step } else { single_step };
760 let current = value.get();
761 let new_val = if positive { current + s } else { current - s };
762 value.set(new_val.clamp(min, max));
763 }
764 };
765
766 let set_value_snapped = {
780 let value = value.clone();
781 move |v: f32| {
782 let mut val = v;
783 if snap_step > 0.0 {
784 val = ((val - min) / snap_step).round() * snap_step + min;
785 }
786 value.set(val.clamp(min, max));
787 }
788 };
789
790 let set_value_from_position = {
791 let set_value_snapped = set_value_snapped.clone();
792 let cached_bounds = cached_bounds.clone();
793 move |x: f32, y: f32, rtl: bool| {
794 let bounds = cached_bounds.get();
795 let pos = match orientation {
796 Orientation::Horizontal => x,
797 Orientation::Vertical => y,
798 };
799 let usable = match orientation {
800 Orientation::Horizontal => bounds.width,
801 Orientation::Vertical => bounds.height,
802 } - thumb_radius * 2.0;
803 if usable <= 0.0 {
804 return;
805 }
806 let mut t = ((pos - thumb_radius) / usable).clamp(0.0, 1.0);
810 match orientation {
822 Orientation::Horizontal => {
823 if rtl {
824 t = 1.0 - t;
825 }
826 }
827 Orientation::Vertical => t = 1.0 - t,
828 }
829 set_value_snapped(min + t * (max - min));
830 }
831 };
832
833 let mut handlers = HandlerSet::new()
836 .focusable(true)
837 .cursor(CursorIcon::Pointer)
838 .touch_action(TouchAction::NONE);
847
848 let drag_pointer: Rc<Cell<Option<PointerId>>> = Rc::new(Cell::new(None));
851
852 {
854 let dragging = dragging.clone();
855 let drag_pointer = drag_pointer.clone();
856 let set_value = set_value_from_position.clone();
857 let value_before = value.clone();
858 let report = report.clone();
859 let sync_readout = sync_readout.clone();
860 let taps = taps.clone();
861 let drag_at = drag_at.clone();
862 handlers = handlers.on_drag(move |phase, ctx| {
863 match phase {
864 DragPhase::Started {
865 position,
866 button: PointerButton::Primary,
867 pointer,
868 } => {
869 taps.set(TapStreak::EMPTY);
872 dragging.set(true);
873 drag_pointer.set(Some(pointer.id));
874 if follows_pointer {
875 let now = ctx
878 .pointer_position()
879 .and_then(|p| ctx.to_local(p))
880 .unwrap_or(position);
881 drag_at.set(Some(now));
882 }
883 let before = value_before.get();
884 set_value(position.x, position.y, ctx.is_rtl());
885 report(before, ctx);
886 }
887 DragPhase::Moved { position, .. } if dragging.get() => {
888 if follows_pointer {
889 drag_at.set(Some(position));
890 }
891 let before = value_before.get();
892 set_value(position.x, position.y, ctx.is_rtl());
893 report(before, ctx);
894 }
895 DragPhase::Ended { .. } | DragPhase::Cancelled { .. } => {
899 dragging.set(false);
900 drag_pointer.set(None);
901 if drag_at.get().is_some() {
902 drag_at.set(None);
903 }
904 }
905 _ => return,
906 }
907 sync_readout(ctx);
908 });
909 }
910
911 {
920 let dragging = dragging.clone();
921 let sync_readout = sync_readout.clone();
922 let drag_at = drag_at.clone();
923 handlers = handlers.on_pointer_cancel(move |pointer, _reason, ctx| {
924 if drag_pointer.get() != Some(pointer.id) {
925 return;
926 }
927 drag_pointer.set(None);
928 dragging.set(false);
929 if drag_at.get().is_some() {
930 drag_at.set(None);
931 }
932 sync_readout(ctx);
933 });
934 }
935
936 let press_jumped = Rc::new(Cell::new(false));
939
940 if self.jump_on_press {
945 let set_value = set_value_from_position.clone();
946 let value_before = value.clone();
947 let report = report.clone();
948 let sync_readout = sync_readout.clone();
949 let press_jumped = press_jumped.clone();
950 let taps = taps.clone();
951 let resets = reset_to_default.is_some();
952 let input = ctx.theme().input;
953 handlers = handlers.on_pointer_event(move |event, ctx| {
954 let WidgetEvent::PointerDown {
955 position,
956 button: PointerButton::Primary,
957 pointer,
958 ..
959 } = event
960 else {
961 return EventResponse::Ignored;
962 };
963 press_jumped.set(false);
964 if pointer.kind.is_direct() {
965 return EventResponse::Ignored;
966 }
967 if resets {
971 let mut streak = taps.get();
972 let profile = input.profile(pointer.kind);
973 if streak.advance(pointer.time, profile, *position, PointerButton::Primary) >= 2
974 {
975 return EventResponse::Ignored;
976 }
977 }
978 let before = value_before.get();
979 set_value(position.x, position.y, ctx.is_rtl());
980 report(before, ctx);
981 press_jumped.set(true);
982 sync_readout(ctx);
983 EventResponse::Ignored
986 });
987 }
988
989 {
1003 let set_value = set_value_from_position.clone();
1004 let value_before = value.clone();
1005 let report = report.clone();
1006 let sync_readout = sync_readout.clone();
1007 let reset = reset_to_default.clone();
1008 let taps = taps.clone();
1009 let input = ctx.theme().input;
1012 let press_jumped = press_jumped.clone();
1013 handlers = handlers.on_tap(move |event, ctx| {
1014 if event.button != PointerButton::Primary {
1018 return;
1019 }
1020 if let Some(ref reset) = reset {
1021 let profile = input.profile(event.pointer.kind);
1024 let mut streak = taps.get();
1025 let count =
1026 streak.advance(event.pointer.time, profile, event.position, event.button);
1027 if count >= 2 {
1028 let mut rebased = TapStreak::EMPTY;
1034 rebased.advance(event.pointer.time, profile, event.position, event.button);
1035 taps.set(rebased);
1036 reset(ctx);
1037 return;
1038 }
1039 taps.set(streak);
1040 }
1041 if press_jumped.replace(false) {
1044 sync_readout(ctx);
1045 return;
1046 }
1047 let before = value_before.get();
1048 set_value(event.position.x, event.position.y, ctx.is_rtl());
1049 report(before, ctx);
1050 sync_readout(ctx);
1051 });
1052 }
1053
1054 handlers = handlers.hover_within(hovered.clone());
1065 {
1066 let hovered = hovered.clone();
1067 let sync_readout = sync_readout.clone();
1068 handlers = handlers.on_hover(move |entered, ctx| {
1069 hovered.set(entered);
1070 sync_readout(ctx);
1071 });
1072 }
1073
1074 {
1077 let adjust = adjust_by_step.clone();
1078 let value = value.clone();
1079 let report = report.clone();
1080 let sync_readout = sync_readout.clone();
1081 handlers = handlers.on_key(move |event, ctx| {
1082 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1083 return EventResponse::Ignored;
1084 };
1085 let response = match range_nav::range_move(
1091 *key,
1092 *modifiers,
1093 RangeKind::Scalar,
1094 RangeAxis::Both,
1095 ctx.is_rtl(),
1096 ) {
1097 Some(mv) => {
1098 let before = value.get();
1099 match mv {
1100 RangeMove::Step { increase } => adjust(increase, false),
1101 RangeMove::Page { increase } => adjust(increase, true),
1102 RangeMove::ToMin => value.set(min),
1103 RangeMove::ToMax => value.set(max),
1104 }
1105 report(before, ctx);
1106 EventResponse::Handled
1107 }
1108 None => EventResponse::Ignored,
1109 };
1110 if *key != Key::Escape {
1116 sync_readout(ctx);
1117 }
1118 response
1119 });
1120 }
1121
1122 {
1127 let focused = focused.clone();
1128 let sync_readout = sync_readout.clone();
1129 handlers = handlers.on_focus(move |gained, ctx| {
1130 focused.set(gained);
1131 sync_readout(ctx);
1132 });
1133 }
1134
1135 {
1147 let adjust = adjust_by_step.clone();
1148 let set_snapped = set_value_snapped.clone();
1149 let value_before = value.clone();
1150 let report = report.clone();
1151 let reset = reset_to_default.clone();
1152 let sync_readout = sync_readout.clone();
1153 handlers = handlers.on_access_action_request(move |action, _node, data, ctx| {
1154 use teksilo_core::accesskit::{Action, ActionData};
1155 if action == Action::CustomAction
1157 && matches!(data, Some(ActionData::CustomAction(RESET_ACTION_ID)))
1158 && let Some(ref reset) = reset
1159 {
1160 reset(ctx);
1161 return EventResponse::Handled;
1162 }
1163 let before = value_before.get();
1164 let outcome = match (action, data) {
1165 (Action::Increment, _) => {
1166 adjust(true, false);
1167 EventResponse::Handled
1168 }
1169 (Action::Decrement, _) => {
1170 adjust(false, false);
1171 EventResponse::Handled
1172 }
1173 (Action::SetValue, Some(ActionData::NumericValue(v))) if v.is_finite() => {
1177 set_snapped(v as f32);
1178 EventResponse::Handled
1179 }
1180 (Action::SetValue, Some(ActionData::Value(s))) => {
1186 match s.trim().parse::<f32>() {
1187 Ok(v) if v.is_finite() => {
1188 set_snapped(v);
1189 EventResponse::Handled
1190 }
1191 _ => EventResponse::Ignored,
1192 }
1193 }
1194 _ => EventResponse::Ignored,
1195 };
1196 if outcome == EventResponse::Handled {
1197 report(before, ctx);
1198 sync_readout(ctx);
1199 }
1200 outcome
1201 });
1202 }
1203
1204 ctx.apply_self_handlers(handlers);
1205
1206 if let Some(content) = self.composite_tooltip_content.take() {
1209 let delay = ctx.theme().motion.tooltip_delay_heavy;
1210 crate::tooltip::attach_composite_tooltip_boxed(ctx, body_id, content, delay);
1211 } else if let Some(source) = self.rich_tooltip_source.clone() {
1212 let delay = ctx.theme().motion.tooltip_delay;
1213 crate::tooltip::attach_rich_tooltip_source(ctx, body_id, source, delay);
1214 } else if let Some(text) = self.tooltip_text.clone() {
1215 let delay = ctx.theme().motion.tooltip_delay;
1216 crate::tooltip::attach_plain_tooltip(ctx, body_id, text, delay);
1217 }
1218
1219 vec![body_id]
1220 }
1221
1222 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1223 self.body_id
1224 .and_then(|id| ctx.child_size(id, proposal))
1225 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1226 .into()
1227 }
1228
1229 fn place_children(
1230 &self,
1231 bounds: Rect,
1232 _proposal: SizeProposal,
1233 children: &mut [WidgetPlacement],
1234 ctx: &LayoutContext,
1235 ) {
1236 self.cached_bounds.set(bounds);
1238 self.cached_rtl.set(ctx.is_rtl());
1239 if let Some(child) = children.first_mut() {
1240 child.origin = bounds.origin();
1241 child.size = bounds.size();
1242 }
1243 }
1244
1245 fn children(&self) -> Vec<WidgetId> {
1246 self.body_id.into_iter().collect()
1247 }
1248
1249 fn target_regions(&self, bounds: Rect) -> Vec<teksilo_core::partition::TargetRegion> {
1263 use teksilo_core::partition::TargetRegion;
1264
1265 let mut regions = vec![TargetRegion::target(bounds, SLIDER_PART_BODY)];
1266 let diameter = self.thumb_diameter.get();
1267 if diameter <= 0.0 || !diameter.is_finite() {
1268 return regions;
1269 }
1270 regions.push(TargetRegion::grab(
1271 thumb_rect(
1272 bounds,
1273 fraction(self.value.get(), self.min, self.max),
1274 diameter,
1275 self.orientation,
1276 self.cached_rtl.get(),
1277 ),
1278 SLIDER_PART_THUMB,
1279 ));
1280 regions
1281 }
1282
1283 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1284 builder.set_role(teksilo_core::accesskit::Role::Slider);
1285 if let Some(ref label) = self.label {
1286 builder.set_name(label.resolve_now());
1287 }
1288 let step = self.effective_step();
1289 builder.set_numeric_value(on_grid(self.value.get(), self.min, step));
1290 builder.set_min_numeric_value(as_written(self.min));
1291 builder.set_max_numeric_value(as_written(self.max));
1292 builder.set_numeric_value_step(on_grid(step, self.min, step));
1297 builder.set_numeric_value_jump(on_grid(self.effective_page_step(), self.min, step));
1298 let orientation = match self.orientation {
1299 Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
1300 Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
1301 };
1302 builder.set_orientation(orientation);
1303 builder.add_action(teksilo_core::accesskit::Action::Increment);
1305 builder.add_action(teksilo_core::accesskit::Action::Decrement);
1306 builder.add_action(teksilo_core::accesskit::Action::SetValue);
1310 builder.add_action(teksilo_core::accesskit::Action::Focus);
1311 if let Some(ref format) = self.value_format {
1314 builder.set_value(format(self.value.get()).resolve_now());
1315 }
1316 let enabled = self.effective_enabled.as_ref().is_none_or(|on| on.get());
1320 if self.default_value.is_some() && enabled {
1321 builder.add_action(teksilo_core::accesskit::Action::CustomAction);
1322 builder.set_custom_actions(vec![teksilo_core::accesskit::CustomAction {
1323 id: RESET_ACTION_ID,
1324 description: teksilo_i18n::tr_widget!(slider_reset_to_default()).resolve_now(),
1325 }]);
1326 }
1327 }
1328}
1329
1330fn fraction(value: f32, min: f32, max: f32) -> f32 {
1333 let range = max - min;
1334 if range.abs() < f32::EPSILON {
1335 0.0
1336 } else {
1337 ((value - min) / range).clamp(0.0, 1.0)
1338 }
1339}
1340
1341fn thumb_rect(bounds: Rect, t: f32, diameter: f32, orientation: Orientation, rtl: bool) -> Rect {
1350 let radius = diameter * 0.5;
1351 let (cx, cy) = match orientation {
1352 Orientation::Horizontal => {
1353 let usable = (bounds.width - diameter).max(0.0);
1354 let t = if rtl { 1.0 - t } else { t };
1355 (
1356 bounds.x + radius + usable * t,
1357 bounds.y + bounds.height * 0.5,
1358 )
1359 }
1360 Orientation::Vertical => {
1361 let usable = (bounds.height - diameter).max(0.0);
1362 (
1363 bounds.x + bounds.width * 0.5,
1364 bounds.y + radius + usable * (1.0 - t),
1365 )
1366 }
1367 };
1368 Rect::new(cx - radius, cy - radius, diameter, diameter)
1369}
1370
1371fn as_written(value: f32) -> f64 {
1378 value.to_string().parse().unwrap_or(f64::from(value))
1379}
1380
1381fn on_grid(figure: f32, min: f32, step: f32) -> f64 {
1391 fn places(value: f32) -> usize {
1392 value
1394 .to_string()
1395 .split_once('.')
1396 .map_or(0, |(_, fraction)| fraction.len())
1397 }
1398 let written = as_written(figure);
1399 let places = places(min).max(places(step));
1400 let rounded: f64 = format!("{written:.places$}").parse().unwrap_or(written);
1401 if (rounded - written).abs() <= as_written(step).abs() * 1e-3 {
1402 rounded
1403 } else {
1404 written
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411 use teksilo_canvas::Point;
1412 use teksilo_core::event::{Key, Modifiers};
1413 use teksilo_core::widget_tree::WidgetTree;
1414
1415 #[test]
1416 fn focus_ring_only_under_focus_visible() {
1417 let theme = teksilo_core::presets::intui::light();
1422 let ring = theme.colors.focus_ring.to_array();
1423 let mut tree = WidgetTree::new().with_theme(theme);
1424 let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
1425 tree.layout(SizeProposal::exact(200.0, 60.0));
1426
1427 tree.focus(s);
1428 assert!(
1429 !frame_has_ring(&tree.render(), ring),
1430 "no focus ring while focus-visible is false (pointer modality)",
1431 );
1432
1433 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1434 assert!(
1435 frame_has_ring(&tree.render(), ring),
1436 "focus ring shows under keyboard modality",
1437 );
1438 }
1439
1440 fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
1445 frame
1446 .shapes
1447 .iter()
1448 .any(|s| s.color == color && s.stroke_width > 0.0)
1449 || frame.cosmetic_lines.iter().any(|l| l.color == color)
1450 }
1451
1452 fn thumb_ring(offset: f32) -> [f32; 4] {
1455 let mut theme = teksilo_core::presets::intui::light();
1456 theme.shape.focus_ring_offset = offset;
1457 theme.shape.focus_ring_width = 2.0;
1458 let ring = theme.colors.focus_ring.to_array();
1459 let mut tree = WidgetTree::new().with_theme(theme);
1460 let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
1461 tree.layout(SizeProposal::exact(200.0, 60.0));
1462 tree.focus(s);
1463 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1464 tree.layout(SizeProposal::exact(200.0, 60.0));
1465 let frame = tree.render();
1466 frame
1467 .shapes
1468 .iter()
1469 .find(|s| s.color == ring && s.stroke_width > 0.0)
1470 .expect("the keyboard ring is stroked")
1471 .screen
1472 }
1473
1474 #[test]
1475 fn a_negative_focus_ring_offset_shrinks_the_thumb_ring_without_inverting() {
1476 let outer = thumb_ring(2.0);
1477 let inner = thumb_ring(-1.0);
1478 assert!(inner[2] < outer[2] && inner[3] < outer[3]);
1479 assert!(inner[2] >= 0.0 && inner[3] >= 0.0);
1480
1481 let deep = thumb_ring(-40.0);
1482 assert!(deep[2] >= 0.0 && deep[3] >= 0.0, "ring inverted: {deep:?}");
1483 }
1484
1485 #[test]
1486 fn keyboard_adjusts_value() {
1487 let value = Signal::new(50.0_f32);
1488 let mut tree = WidgetTree::new();
1489 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1490 tree.layout(SizeProposal::exact(200.0, 60.0));
1491
1492 tree.focus(s);
1493 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1494 assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1495
1496 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1497 assert!((value.get() - 50.0).abs() < 0.01);
1498 }
1499
1500 #[test]
1501 fn on_change_reports_each_value_the_user_produces_and_nothing_else() {
1502 use std::cell::RefCell;
1503 use std::rc::Rc;
1504
1505 let value = Signal::new(50.0_f32);
1506 let seen: Rc<RefCell<Vec<f32>>> = Rc::new(RefCell::new(Vec::new()));
1507 let sink = seen.clone();
1508 let mut tree = WidgetTree::new();
1509 let s = tree.add(
1510 Slider::new(value.clone(), 0.0, 100.0)
1511 .step(10.0)
1512 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
1513 );
1514 tree.layout(SizeProposal::exact(200.0, 60.0));
1515 tree.focus(s);
1516
1517 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1518 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1519 tree.press_key(Key::End, Modifiers::NONE);
1520 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1523
1524 assert_eq!(*seen.borrow(), vec![60.0, 50.0, 100.0]);
1525 }
1526
1527 #[test]
1528 fn on_change_is_silent_for_a_programmatic_write() {
1529 use std::cell::Cell;
1530 use std::rc::Rc;
1531
1532 let value = Signal::new(50.0_f32);
1533 let fired = Rc::new(Cell::new(false));
1534 let sink = fired.clone();
1535 let mut tree = WidgetTree::new();
1536 let _s = tree.add(
1537 Slider::new(value.clone(), 0.0, 100.0)
1538 .step(10.0)
1539 .on_change(move |_now, _ctx| sink.set(true)),
1540 );
1541 tree.layout(SizeProposal::exact(200.0, 60.0));
1542
1543 value.set(70.0);
1544 assert!(!fired.get());
1545 }
1546
1547 #[test]
1548 fn home_end_jump_to_bounds() {
1549 let value = Signal::new(50.0_f32);
1550 let mut tree = WidgetTree::new();
1551 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1552 tree.layout(SizeProposal::exact(200.0, 60.0));
1553
1554 tree.focus(s);
1555 tree.press_key(Key::Home, Modifiers::NONE);
1556 assert!((value.get() - 0.0).abs() < 0.01);
1557
1558 tree.press_key(Key::End, Modifiers::NONE);
1559 assert!((value.get() - 100.0).abs() < 0.01);
1560 }
1561
1562 fn a11y_steps(
1568 tree: &mut WidgetTree,
1569 id: teksilo_core::widget_id::WidgetId,
1570 ) -> (Option<f64>, Option<f64>) {
1571 let update = tree.sync_accessibility();
1572 let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1573 update
1574 .nodes
1575 .iter()
1576 .find(|(node_id, _)| *node_id == nid)
1577 .map(|(_, n)| (n.numeric_value_step(), n.numeric_value_jump()))
1578 .expect("the slider publishes an AT node")
1579 }
1580
1581 fn access(
1582 tree: &mut WidgetTree,
1583 id: teksilo_core::widget_id::WidgetId,
1584 action: teksilo_core::accesskit::Action,
1585 data: Option<teksilo_core::accesskit::ActionData>,
1586 ) -> bool {
1587 let mut ops = teksilo_core::window::NoopWindowOps;
1588 tree.dispatch_access_action(
1589 teksilo_core::accessibility::widget_id_to_node_id(id),
1590 action,
1591 data,
1592 &mut ops,
1593 )
1594 }
1595
1596 #[test]
1597 fn page_keys_move_ten_arrow_steps() {
1598 let value = Signal::new(50.0_f32);
1603 let mut tree = WidgetTree::new();
1604 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1605 tree.layout(SizeProposal::exact(200.0, 60.0));
1606 tree.focus(s);
1607
1608 tree.press_key(Key::PageUp, Modifiers::NONE);
1609 assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1610 tree.press_key(Key::PageDown, Modifiers::NONE);
1611 assert!((value.get() - 50.0).abs() < 0.01, "value={}", value.get());
1612 }
1613
1614 #[test]
1615 fn page_step_overrides_the_default() {
1616 let value = Signal::new(50.0_f32);
1617 let mut tree = WidgetTree::new();
1618 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).page_step(25.0));
1619 tree.layout(SizeProposal::exact(200.0, 60.0));
1620 tree.focus(s);
1621
1622 tree.press_key(Key::PageUp, Modifiers::NONE);
1623 assert!((value.get() - 75.0).abs() < 0.01, "value={}", value.get());
1624 }
1625
1626 #[test]
1627 fn page_step_follows_an_explicit_step() {
1628 let value = Signal::new(50.0_f32);
1630 let mut tree = WidgetTree::new();
1631 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(2.0));
1632 tree.layout(SizeProposal::exact(200.0, 60.0));
1633 tree.focus(s);
1634
1635 tree.press_key(Key::PageUp, Modifiers::NONE);
1636 assert!((value.get() - 70.0).abs() < 0.01, "value={}", value.get());
1637 }
1638
1639 #[test]
1640 fn paging_clamps_at_the_bounds() {
1641 let value = Signal::new(95.0_f32);
1642 let mut tree = WidgetTree::new();
1643 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1644 tree.layout(SizeProposal::exact(200.0, 60.0));
1645 tree.focus(s);
1646
1647 tree.press_key(Key::PageUp, Modifiers::NONE);
1648 assert!((value.get() - 100.0).abs() < 0.01, "value={}", value.get());
1649 }
1650
1651 #[test]
1652 fn an_accelerator_chord_leaves_the_value_alone() {
1653 let value = Signal::new(50.0_f32);
1657 let mut tree = WidgetTree::new();
1658 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1659 tree.layout(SizeProposal::exact(200.0, 60.0));
1660 tree.focus(s);
1661
1662 for (key, mods) in [
1663 (Key::Home, Modifiers::CTRL),
1664 (Key::End, Modifiers::ALT),
1665 (Key::ArrowRight, Modifiers::CTRL),
1666 (Key::PageDown, Modifiers::SUPER),
1667 ] {
1668 tree.press_key(key, mods);
1669 assert!(
1670 (value.get() - 50.0).abs() < 0.01,
1671 "{key:?} with {mods:?} moved the value to {}",
1672 value.get()
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn a_shifted_arrow_still_steps() {
1679 let value = Signal::new(50.0_f32);
1682 let mut tree = WidgetTree::new();
1683 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1684 tree.layout(SizeProposal::exact(200.0, 60.0));
1685 tree.focus(s);
1686
1687 tree.press_key(Key::ArrowRight, Modifiers::SHIFT);
1688 assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1689 }
1690
1691 #[test]
1692 fn accessibility_publishes_the_page_step_as_the_value_jump() {
1693 let mut tree = WidgetTree::new();
1696 let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
1697 tree.layout(SizeProposal::exact(200.0, 60.0));
1698 assert_eq!(a11y_steps(&mut tree, s), (Some(1.0), Some(10.0)));
1699
1700 let mut tree = WidgetTree::new();
1701 let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0).page_step(25.0));
1702 tree.layout(SizeProposal::exact(200.0, 60.0));
1703 assert_eq!(a11y_steps(&mut tree, s).1, Some(25.0));
1704 }
1705
1706 #[test]
1707 fn a_reader_is_told_each_new_value_as_it_happens() {
1708 use crate::common::heard_test::{Heard, Listener};
1712
1713 let value = Signal::new(50.0_f32);
1714 let mut tree = WidgetTree::new();
1715 let s =
1716 tree.add(Slider::new(value.clone(), 0.0, 100.0).label(teksilo_i18n::lit!("Volume")));
1717 let p = SizeProposal::exact(200.0, 60.0);
1718 tree.layout(p);
1719 tree.focus(s);
1720 tree.layout(p);
1721 let mut listener = Listener::attach(&mut tree);
1722
1723 for heard in ["51", "52"] {
1724 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1725 tree.layout(p);
1726 assert_eq!(
1727 listener.heard(&mut tree),
1728 vec![Heard::FocusNumber(heard.to_string())],
1729 "Right"
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn a_reader_hears_the_figure_the_app_holds_not_its_binary_noise() {
1736 use crate::common::heard_test::{Heard, Listener};
1740
1741 let value = Signal::new(0.3_f32);
1742 let mut tree = WidgetTree::new();
1743 let s = tree.add(
1744 Slider::new(value.clone(), 0.0, 1.0)
1745 .step(0.01)
1746 .orientation(Orientation::Vertical)
1747 .label(teksilo_i18n::lit!("Opacity")),
1748 );
1749 let p = SizeProposal::exact(60.0, 200.0);
1750 tree.layout(p);
1751
1752 let update = tree.sync_accessibility();
1753 let nid = teksilo_core::accessibility::widget_id_to_node_id(s);
1754 let node = &update
1755 .nodes
1756 .iter()
1757 .find(|(id, _)| *id == nid)
1758 .expect("the slider publishes an AT node")
1759 .1;
1760 assert_eq!(
1761 (node.numeric_value(), node.numeric_value_step()),
1762 (Some(0.3), Some(0.01)),
1763 );
1764
1765 tree.focus(s);
1766 tree.layout(p);
1767 let mut listener = Listener::attach(&mut tree);
1768 tree.press_key(Key::ArrowUp, Modifiers::NONE);
1769 tree.layout(p);
1770 assert_eq!(
1771 listener.heard(&mut tree),
1772 vec![Heard::FocusNumber("0.31".to_string())],
1773 );
1774 }
1775
1776 #[test]
1777 fn a_reader_hears_each_step_on_its_grid_not_the_drift_of_f32_sums() {
1778 use crate::common::heard_test::{Heard, Listener};
1783
1784 let value = Signal::new(0.65_f32);
1787 let mut tree = WidgetTree::new();
1788 let s = tree.add(Slider::new(value.clone(), 0.0, 1.0).label(teksilo_i18n::lit!("Opacity")));
1789 let p = SizeProposal::exact(200.0, 60.0);
1790 tree.layout(p);
1791 assert_eq!(
1792 a11y_steps(&mut tree, s),
1793 (Some(0.01), Some(0.1)),
1794 "the arrow and page distances"
1795 );
1796 tree.focus(s);
1797 tree.layout(p);
1798 let mut listener = Listener::attach(&mut tree);
1799 for heard in ["0.66", "0.67", "0.68"] {
1800 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1801 tree.layout(p);
1802 assert_eq!(
1803 listener.heard(&mut tree),
1804 vec![Heard::FocusNumber(heard.to_string())],
1805 "Right"
1806 );
1807 }
1808
1809 value.set(0.3);
1810 tree.layout(p);
1811 let _ = listener.heard(&mut tree);
1812 for heard in ["0.31", "0.32", "0.33"] {
1813 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1814 tree.layout(p);
1815 assert_eq!(
1816 listener.heard(&mut tree),
1817 vec![Heard::FocusNumber(heard.to_string())],
1818 "Right from 0.3"
1819 );
1820 }
1821
1822 value.set(0.655);
1825 tree.layout(p);
1826 assert_eq!(
1827 listener.heard(&mut tree),
1828 vec![Heard::FocusNumber("0.655".to_string())],
1829 "a value off the grid"
1830 );
1831 }
1832
1833 #[test]
1834 fn access_set_value_writes_and_snaps() {
1835 use teksilo_core::accesskit::{Action, ActionData};
1839 let value = Signal::new(50.0_f32);
1840 let mut tree = WidgetTree::new();
1841 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1842 tree.layout(SizeProposal::exact(200.0, 60.0));
1843
1844 assert!(
1845 tree.accessibility_node(s)
1846 .actions()
1847 .contains(&Action::SetValue),
1848 "macOS gates AXValue settability on the advertisement"
1849 );
1850
1851 assert!(access(
1852 &mut tree,
1853 s,
1854 Action::SetValue,
1855 Some(ActionData::NumericValue(73.0))
1856 ));
1857 assert!(
1858 (value.get() - 70.0).abs() < 0.01,
1859 "an AT write snaps to `step`, like a drag: {}",
1860 value.get()
1861 );
1862
1863 assert!(access(
1865 &mut tree,
1866 s,
1867 Action::SetValue,
1868 Some(ActionData::Value("1000".into()))
1869 ));
1870 assert!((value.get() - 100.0).abs() < 0.01, "out of range clamps");
1871
1872 assert!(!access(
1875 &mut tree,
1876 s,
1877 Action::SetValue,
1878 Some(ActionData::Value("seventy".into()))
1879 ));
1880 assert!((value.get() - 100.0).abs() < 0.01);
1881 }
1882
1883 #[test]
1884 fn access_increment_survives_the_payload_handler() {
1885 use teksilo_core::accesskit::Action;
1891 let value = Signal::new(50.0_f32);
1892 let mut tree = WidgetTree::new();
1893 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1894 tree.layout(SizeProposal::exact(200.0, 60.0));
1895
1896 assert!(access(&mut tree, s, Action::Increment, None));
1897 assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1898 assert!(access(&mut tree, s, Action::Decrement, None));
1899 assert!((value.get() - 50.0).abs() < 0.01, "value={}", value.get());
1900 }
1901
1902 #[test]
1903 fn an_app_installed_access_action_still_fires_on_a_payload_widget() {
1904 use std::cell::Cell;
1911 use std::rc::Rc;
1912 use teksilo_core::accesskit::Action;
1913 use teksilo_core::widget_builder::WidgetBuilder;
1914
1915 let seen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
1916 let seen_h = seen.clone();
1917 let value = Signal::new(50.0_f32);
1918 let mut tree = WidgetTree::new();
1919 let s = tree.add(
1920 Slider::new(value.clone(), 0.0, 100.0)
1921 .step(10.0)
1922 .on_access_action(move |action, _ctx| {
1923 if action == Action::Increment {
1924 seen_h.set(seen_h.get() + 1);
1925 }
1926 EventResponse::Ignored
1927 }),
1928 );
1929 tree.layout(SizeProposal::exact(200.0, 60.0));
1930
1931 assert!(access(&mut tree, s, Action::Increment, None));
1932 assert_eq!(seen.get(), 1, "the app's handler runs");
1933 assert!(
1934 (value.get() - 60.0).abs() < 0.01,
1935 "and the widget's own still steps: {}",
1936 value.get()
1937 );
1938 }
1939
1940 #[test]
1941 fn rtl_mirrors_the_arrows_the_pointer_and_the_fill_together() {
1942 let value = Signal::new(50.0_f32);
1948 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1949 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1950 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1951 tree.layout(SizeProposal::exact(200.0, 60.0));
1952 tree.render();
1953 tree.focus(s);
1954
1955 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1956 assert!(
1957 (value.get() - 60.0).abs() < 0.01,
1958 "under RTL the leftward arrow increases, got {}",
1959 value.get()
1960 );
1961 tree.press_key(Key::ArrowRight, Modifiers::NONE);
1962 assert!((value.get() - 50.0).abs() < 0.01);
1963
1964 let p = Point::new(190.0, 30.0);
1968 tree.pointer_move(p);
1969 tree.dispatch_event(WidgetEvent::pointer_down(
1970 p,
1971 PointerButton::Primary,
1972 Modifiers::NONE,
1973 ));
1974 tree.dispatch_event(WidgetEvent::pointer_up(
1975 p,
1976 PointerButton::Primary,
1977 Modifiers::NONE,
1978 ));
1979 assert!(
1980 value.get() < 20.0,
1981 "a click near the right edge is near the minimum under RTL, got {}",
1982 value.get()
1983 );
1984 }
1985
1986 #[test]
1987 fn an_at_write_lands_on_the_grid_the_arrows_walk() {
1988 use teksilo_core::accesskit::{Action, ActionData};
1995 let value = Signal::new(50.0_f32);
1996 let mut tree = WidgetTree::new();
1997 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1998 tree.layout(SizeProposal::exact(200.0, 60.0));
1999
2000 assert!(access(
2001 &mut tree,
2002 s,
2003 Action::SetValue,
2004 Some(ActionData::NumericValue(37.3))
2005 ));
2006 assert!(
2007 (value.get() - 37.0).abs() < 0.01,
2008 "a stepless 0..100 slider steps by 1, so the write snaps there: {}",
2009 value.get()
2010 );
2011
2012 tree.focus(s);
2014 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2015 assert!((value.get() - 38.0).abs() < 0.01, "value={}", value.get());
2016 }
2017
2018 #[test]
2019 fn a_vertical_slider_puts_its_maximum_at_the_top() {
2020 let value = Signal::new(50.0_f32);
2027 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2028 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).orientation(Orientation::Vertical));
2029 tree.layout(SizeProposal::exact(60.0, 200.0));
2030 tree.render();
2031
2032 let top = Point::new(30.0, 12.0);
2034 tree.pointer_move(top);
2035 for ev in [
2036 WidgetEvent::pointer_down(top, PointerButton::Primary, Modifiers::NONE),
2037 WidgetEvent::pointer_up(top, PointerButton::Primary, Modifiers::NONE),
2038 ] {
2039 tree.dispatch_event(ev);
2040 }
2041 assert!(
2042 value.get() > 90.0,
2043 "a click near the top is near the maximum, got {}",
2044 value.get()
2045 );
2046
2047 value.set(50.0);
2049 tree.focus(s);
2050 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2051 let raised = value.get();
2052 assert!(raised > 50.0, "ArrowUp increases, got {raised}");
2053
2054 let thumb_y = |tree: &mut WidgetTree| -> f32 {
2056 let radius = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER;
2057 tree.render()
2058 .shapes
2059 .iter()
2060 .filter(|q| (q.screen[3] - radius).abs() < 1.0)
2061 .map(|q| q.screen[1])
2062 .fold(f32::MAX, f32::min)
2063 };
2064 value.set(10.0);
2065 let low = thumb_y(&mut tree);
2066 value.set(90.0);
2067 let high = thumb_y(&mut tree);
2068 assert!(
2069 high < low,
2070 "the thumb rises as the value grows: y({}) at 90 vs y({}) at 10",
2071 high,
2072 low
2073 );
2074 }
2075
2076 #[test]
2077 fn a_vertical_slider_ignores_the_layout_direction() {
2078 let value = Signal::new(50.0_f32);
2081 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2082 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
2083 let s = tree.add(
2084 Slider::new(value.clone(), 0.0, 100.0)
2085 .step(10.0)
2086 .orientation(Orientation::Vertical),
2087 );
2088 tree.layout(SizeProposal::exact(60.0, 200.0));
2089 tree.focus(s);
2090
2091 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2092 assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
2093 }
2094
2095 #[test]
2096 fn track_click_sets_value() {
2097 let value = Signal::new(0.0_f32);
2098 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2099 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2100 tree.layout(SizeProposal::exact(200.0, 60.0));
2101 tree.render();
2104
2105 tree.click(s);
2107
2108 let val = value.get();
2110 assert!(
2111 (val - 50.0).abs() < 15.0,
2112 "track click at center should set value near 50, got {}",
2113 val
2114 );
2115 }
2116
2117 #[test]
2118 fn track_click_sets_value_at_nonzero_origin() {
2119 use crate::primitives::{FixedSize, HStack};
2122 use teksilo_canvas::Point;
2123 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2124
2125 let value = Signal::new(0.0_f32);
2126 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2127 let sid = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2128 let _row = tree.add(
2129 HStack::new()
2130 .child(FixedSize::new().width(40.0).height(60.0))
2131 .child(sid),
2132 );
2133 tree.layout(SizeProposal::exact(240.0, 60.0));
2134 tree.render();
2135
2136 let b = tree.bounds(sid);
2140 assert!(
2141 (b.x - 40.0).abs() < 0.5,
2142 "slider should be offset, x={}",
2143 b.x
2144 );
2145 for ev in [
2146 WidgetEvent::pointer_down(
2147 Point::new(140.0, 30.0),
2148 PointerButton::Primary,
2149 Modifiers::NONE,
2150 ),
2151 WidgetEvent::pointer_up(
2152 Point::new(140.0, 30.0),
2153 PointerButton::Primary,
2154 Modifiers::NONE,
2155 ),
2156 ] {
2157 tree.dispatch_event(ev);
2158 }
2159 assert!(
2160 (value.get() - 50.0).abs() < 1.0,
2161 "click at the offset slider's centre should set ~50, got {}",
2162 value.get()
2163 );
2164 }
2165
2166 #[test]
2167 fn accessibility() {
2168 let value = Signal::new(25.0_f32);
2169 let mut tree = WidgetTree::new();
2170 let s = tree.add(Slider::new(value, 0.0, 100.0));
2171 tree.layout(SizeProposal::exact(200.0, 60.0));
2172 let info = tree.accessibility_node(s);
2173 assert_eq!(info.role(), teksilo_core::accesskit::Role::Slider);
2174 }
2175
2176 #[test]
2177 fn step_snaps_value() {
2178 let value = Signal::new(0.0_f32);
2179 let mut tree = WidgetTree::new();
2180 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(25.0));
2181 tree.layout(SizeProposal::exact(200.0, 60.0));
2182
2183 tree.focus(s);
2184 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2185 assert!((value.get() - 25.0).abs() < 0.01);
2186 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2187 assert!((value.get() - 50.0).abs() < 0.01);
2188 }
2189
2190 #[test]
2191 fn thumb_drag_updates_value() {
2192 let theme = teksilo_core::presets::intui::light();
2193 let thumb_radius = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER * 0.5;
2194 let value = Signal::new(50.0_f32);
2195 let mut tree = WidgetTree::new().with_theme(theme);
2196 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2197 tree.layout(SizeProposal::exact(200.0, 60.0));
2198 tree.render(); let bounds = tree.bounds(s);
2201 let thumb_cx = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.5;
2203 let center_y = bounds.y + bounds.height / 2.0;
2204
2205 tree.pointer_down_button(Point::new(thumb_cx, center_y), PointerButton::Primary);
2207
2208 let target_x = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.75;
2213 tree.pointer_move(Point::new(thumb_cx + 10.0, center_y));
2214 tree.pointer_move(Point::new(target_x, center_y));
2215
2216 let val = value.get();
2217 assert!(
2218 (val - 75.0).abs() < 5.0,
2219 "dragging to 75% should set value near 75, got {}",
2220 val
2221 );
2222
2223 tree.pointer_up_button(Point::new(target_x, center_y), PointerButton::Primary);
2225 }
2226
2227 #[test]
2228 fn accessibility_has_actions() {
2229 let value = Signal::new(25.0_f32);
2230 let mut tree = WidgetTree::new();
2231 let s = tree.add(Slider::new(value, 0.0, 100.0));
2232 tree.layout(SizeProposal::exact(200.0, 60.0));
2233 let info = tree.accessibility_node(s);
2234 assert!(
2235 info.actions()
2236 .contains(&teksilo_core::accesskit::Action::Increment)
2237 );
2238 assert!(
2239 info.actions()
2240 .contains(&teksilo_core::accesskit::Action::Decrement)
2241 );
2242 }
2243
2244 #[test]
2260 fn a_finger_drag_on_a_slider_inside_a_scroller_adjusts_the_slider() {
2261 use crate::button::press_test_support::{finger, touch};
2262 use teksilo_core::event::EventResponse;
2263 use teksilo_core::pointer::PointerPhase;
2264 use teksilo_core::pointer::touch_action::PanClaim;
2265 use teksilo_core::widget_builder::WidgetBuilder;
2266
2267 let scrolled = Rc::new(Cell::new(0_u32));
2268 let count = scrolled.clone();
2269 let value = Signal::new(0.0_f32);
2270 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2271 let slider = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2272 let _list = tree.add(
2273 crate::primitives::VStack::new()
2274 .child(slider)
2275 .scroll_container(teksilo_core::pointer::touch_action::PanAxes::BOTH)
2276 .pan_claim(PanClaim::vertical())
2277 .on_scroll(move |_e, _c| {
2278 count.set(count.get() + 1);
2279 EventResponse::Handled
2280 }),
2281 );
2282 tree.layout(SizeProposal::exact(200.0, 400.0));
2283 let b = tree.bounds(slider);
2284 let start = Point::new(b.x + 20.0, b.center().y);
2285 let id = finger();
2286 tree.dispatch_pointer(touch(id, PointerPhase::Down, start, 0));
2287
2288 tree.dispatch_pointer(touch(
2292 id,
2293 PointerPhase::Move,
2294 Point::new(start.x + 30.0, start.y + 45.0),
2295 20,
2296 ));
2297 assert!(
2298 value.get() > 0.0,
2299 "the drag must arm on the first move, not after a long press (value {})",
2300 value.get(),
2301 );
2302
2303 for (i, (dx, dy)) in [(60.0_f32, 90.0_f32), (90.0, 140.0)]
2305 .into_iter()
2306 .enumerate()
2307 {
2308 let at = Point::new(start.x + dx, start.y + dy);
2309 tree.dispatch_pointer(touch(id, PointerPhase::Move, at, 40 + i as u64 * 20));
2310 }
2311 tree.dispatch_pointer(touch(
2312 id,
2313 PointerPhase::Up,
2314 Point::new(start.x + 90.0, start.y + 140.0),
2315 100,
2316 ));
2317 assert!(
2318 value.get() > 40.0,
2319 "the finger drag did not reach the slider (value {})",
2320 value.get(),
2321 );
2322 assert_eq!(scrolled.get(), 0, "and it must not have scrolled the list");
2323 }
2324
2325 #[test]
2334 fn two_contacts_on_a_slider_pinch_nothing_underneath() {
2335 use crate::button::press_test_support::{finger, touch};
2336 use teksilo_core::gesture::PinchPhase;
2337 use teksilo_core::pointer::PointerPhase;
2338 use teksilo_core::widget_builder::WidgetBuilder;
2339
2340 let started = Rc::new(Cell::new(0_u32));
2341 let n = started.clone();
2342 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2343 let slider = tree.add(Slider::new(Signal::new(0.0_f32), 0.0, 100.0));
2344 let _surface = tree.add(crate::primitives::ZStack::new().child(slider).on_pinch(
2345 move |phase, _c| {
2346 if matches!(phase, PinchPhase::Started { .. }) {
2347 n.set(n.get() + 1);
2348 }
2349 },
2350 ));
2351 tree.layout(SizeProposal::exact(200.0, 200.0));
2352 let b = tree.bounds(slider);
2353 let a = finger();
2354 let c = finger();
2355 tree.dispatch_pointer(touch(
2356 a,
2357 PointerPhase::Down,
2358 Point::new(b.x + 40.0, b.center().y),
2359 0,
2360 ));
2361 tree.dispatch_pointer(touch(
2362 c,
2363 PointerPhase::Down,
2364 Point::new(b.x + 150.0, b.center().y),
2365 5,
2366 ));
2367
2368 assert_eq!(
2369 started.get(),
2370 0,
2371 "a pinch started on a manipulator must not reach the surface under it",
2372 );
2373 assert!(
2374 !tree.touch_pinch_active(),
2375 "…and no pinch may be running at all",
2376 );
2377 }
2378
2379 #[test]
2385 fn an_rtl_slider_raises_its_value_on_the_leftward_arrow() {
2386 use teksilo_core::environment::LayoutDirection;
2387
2388 for (direction, raising, lowering) in [
2389 (
2390 LayoutDirection::LeftToRight,
2391 Key::ArrowRight,
2392 Key::ArrowLeft,
2393 ),
2394 (
2395 LayoutDirection::RightToLeft,
2396 Key::ArrowLeft,
2397 Key::ArrowRight,
2398 ),
2399 ] {
2400 let value = Signal::new(50.0_f32);
2401 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2402 tree.set_layout_direction(direction);
2403 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
2404 tree.layout(SizeProposal::exact(200.0, 40.0));
2405 tree.focus(s);
2406 tree.press_key(raising, Modifiers::NONE);
2407 assert_eq!(value.get(), 60.0, "{direction:?}: {raising:?} must raise");
2408 tree.press_key(lowering, Modifiers::NONE);
2409 tree.press_key(lowering, Modifiers::NONE);
2410 assert_eq!(value.get(), 40.0, "{direction:?}: {lowering:?} must lower");
2411 }
2412 }
2413
2414 #[test]
2418 fn the_value_axis_mirrors_under_rtl() {
2419 use teksilo_core::environment::LayoutDirection;
2420
2421 let value = Signal::new(0.0_f32);
2422 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2423 tree.set_layout_direction(LayoutDirection::RightToLeft);
2424 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2425 tree.layout(SizeProposal::exact(200.0, 40.0));
2426 let b = tree.bounds(s);
2427
2428 tree.pointer_down_button(
2430 Point::new(b.right() - 4.0, b.center().y),
2431 PointerButton::Primary,
2432 );
2433 tree.pointer_up_button(
2434 Point::new(b.right() - 4.0, b.center().y),
2435 PointerButton::Primary,
2436 );
2437 assert!(
2438 value.get() < 10.0,
2439 "RTL minimum is the right edge, got {}",
2440 value.get()
2441 );
2442
2443 tree.pointer_down_button(Point::new(b.x + 4.0, b.center().y), PointerButton::Primary);
2445 tree.pointer_up_button(Point::new(b.x + 4.0, b.center().y), PointerButton::Primary);
2446 assert!(
2447 value.get() > 90.0,
2448 "RTL maximum is the left edge, got {}",
2449 value.get()
2450 );
2451 }
2452
2453 struct PaintedSlider {
2463 track: teksilo_canvas::Rect,
2464 fill: Option<teksilo_canvas::Rect>,
2465 thumb: teksilo_canvas::Rect,
2466 }
2467
2468 fn painted_slider(
2469 direction: teksilo_core::environment::LayoutDirection,
2470 value: f32,
2471 ) -> PaintedSlider {
2472 use crate::styles::recipe_slider_style::{SLIDER_THUMB_DIAMETER, SLIDER_TRACK_HEIGHT};
2473
2474 let theme = teksilo_core::presets::intui::light();
2475 let accent = theme.colors.accent.to_array();
2476 let sunken = theme.colors.surface_sunken.to_array();
2477 let mut tree = WidgetTree::new().with_theme(theme);
2478 tree.set_layout_direction(direction);
2479 let _s = tree.add(Slider::new(Signal::new(value), 0.0, 100.0));
2480 tree.layout(SizeProposal::exact(200.0, 40.0));
2481 let frame = tree.render();
2482 let rect = |q: &teksilo_canvas::ShapeQuad| {
2483 teksilo_canvas::Rect::new(q.screen[0], q.screen[1], q.screen[2], q.screen[3])
2484 };
2485 let track = frame
2486 .shapes
2487 .iter()
2488 .find(|q| q.color == sunken)
2489 .map(rect)
2490 .expect("the body paints a track");
2491 let thumb = frame
2492 .shapes
2493 .iter()
2494 .find(|q| {
2495 q.color == accent
2496 && (q.screen[2] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2497 && (q.screen[3] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2498 })
2499 .map(rect)
2500 .expect("the body paints a knob");
2501 let fill = frame
2502 .shapes
2503 .iter()
2504 .find(|q| {
2505 q.color == accent
2506 && (q.screen[3] - SLIDER_TRACK_HEIGHT).abs() < 0.01
2507 && q.screen[2] > 0.0
2508 })
2509 .map(rect);
2510 PaintedSlider { track, fill, thumb }
2511 }
2512
2513 #[test]
2522 fn the_reported_thumb_mirrors_with_the_paint() {
2523 use teksilo_core::environment::LayoutDirection;
2524
2525 fn thumb_centre(direction: LayoutDirection) -> f32 {
2526 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2527 tree.set_layout_direction(direction);
2528 let s = tree.add(Slider::new(Signal::new(0.0_f32), 0.0, 100.0));
2529 tree.layout(SizeProposal::exact(200.0, 40.0));
2530 let bounds = tree.bounds(s);
2531 let regions = tree.widget_target_regions(s);
2532 let thumb = regions
2533 .iter()
2534 .find(|r| r.part == SLIDER_PART_THUMB)
2535 .expect("the slider reports its knob");
2536 thumb.rect.center().x - bounds.x
2537 }
2538
2539 let ltr = thumb_centre(LayoutDirection::LeftToRight);
2540 let rtl = thumb_centre(LayoutDirection::RightToLeft);
2541 assert!(
2542 ltr < 20.0,
2543 "at the minimum, LTR puts the knob at the left ({ltr})"
2544 );
2545 assert!(rtl > 180.0, "and RTL puts it at the right ({rtl})");
2546
2547 for direction in [LayoutDirection::LeftToRight, LayoutDirection::RightToLeft] {
2550 let painted = painted_slider(direction, 25.0);
2551 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2552 tree.set_layout_direction(direction);
2553 let s = tree.add(Slider::new(Signal::new(25.0_f32), 0.0, 100.0));
2554 tree.layout(SizeProposal::exact(200.0, 40.0));
2555 let reported = tree
2556 .widget_target_regions(s)
2557 .into_iter()
2558 .find(|r| r.part == SLIDER_PART_THUMB)
2559 .expect("the slider reports its knob");
2560 assert!(
2561 (reported.rect.center().x - painted.thumb.center().x).abs() < 0.51,
2562 "{direction:?}: the slider reports its knob at {} and paints it at {}",
2563 reported.rect.center().x,
2564 painted.thumb.center().x,
2565 );
2566 }
2567 }
2568
2569 #[test]
2579 fn the_rtl_fill_runs_from_the_knob_to_the_leading_edge() {
2580 use teksilo_core::environment::LayoutDirection;
2581
2582 let ltr = painted_slider(LayoutDirection::LeftToRight, 25.0);
2583 let ltr_fill = ltr.fill.expect("a quarter-full slider paints a fill");
2584 assert!(
2585 (ltr_fill.x - ltr.track.x).abs() < 0.51,
2586 "LTR fills from the left edge of the track, not from {}",
2587 ltr_fill.x,
2588 );
2589 assert!(
2590 (ltr_fill.width - ltr.track.width * 0.25).abs() < 0.51,
2591 "LTR fills a quarter of the track, not {} of {}",
2592 ltr_fill.width,
2593 ltr.track.width,
2594 );
2595 assert!(
2596 (ltr_fill.right() - ltr.thumb.center().x).abs() < 0.51,
2597 "LTR: the fill ends at the knob ({} vs {})",
2598 ltr_fill.right(),
2599 ltr.thumb.center().x,
2600 );
2601
2602 let rtl = painted_slider(LayoutDirection::RightToLeft, 25.0);
2603 let rtl_fill = rtl.fill.expect("a quarter-full slider paints a fill");
2604 assert!(
2605 (rtl.thumb.center().x - (rtl.track.x + rtl.track.width * 0.75)).abs() < 0.51,
2606 "RTL puts a quarter-value knob three quarters along the track, not at {}",
2607 rtl.thumb.center().x,
2608 );
2609 assert!(
2610 (rtl_fill.right() - rtl.track.right()).abs() < 0.51,
2611 "RTL fills to the right edge of the track, not to {}",
2612 rtl_fill.right(),
2613 );
2614 assert!(
2615 (rtl_fill.width - rtl.track.width * 0.25).abs() < 0.51,
2616 "RTL fills a quarter of the track, not {} of {}",
2617 rtl_fill.width,
2618 rtl.track.width,
2619 );
2620 assert!(
2621 (rtl_fill.x - rtl.thumb.center().x).abs() < 0.51,
2622 "RTL: the fill starts at the knob ({} vs {})",
2623 rtl_fill.x,
2624 rtl.thumb.center().x,
2625 );
2626 }
2627
2628 #[test]
2644 fn the_reported_knob_is_the_size_the_style_reports() {
2645 use teksilo_core::styles::{
2646 SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant,
2647 };
2648
2649 fn config() -> SliderStyleConfig {
2650 SliderStyleConfig {
2651 value_normalized: Signal::new(0.5),
2652 is_hovered: Signal::new(false),
2653 is_dragging: Signal::new(false),
2654 is_disabled: Signal::new(false),
2655 focus_origin: Signal::new(None),
2656 orientation: SliderOrientation::Horizontal,
2657 tick_count: None,
2658 variant: SliderVariant::Continuous,
2659 }
2660 }
2661
2662 fn reported_knob(diameter: Option<f32>) -> Rect {
2665 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2666 let mut slider = Slider::new(Signal::new(50.0_f32), 0.0, 100.0);
2667 if let Some(diameter) = diameter {
2668 slider = slider.style(FatKnob(diameter));
2669 }
2670 let s = tree.add(slider);
2671 tree.layout(SizeProposal::exact(200.0, 60.0));
2672 tree.widget_target_regions(s)
2673 .into_iter()
2674 .find(|r| r.part == SLIDER_PART_THUMB)
2675 .expect("the slider reports its knob")
2676 .rect
2677 }
2678
2679 struct FatKnob(f32);
2682
2683 impl SliderStyle for FatKnob {
2684 fn make_body(
2685 &self,
2686 cfg: &SliderStyleConfig,
2687 ctx: &mut teksilo_core::build_context::BuildContext,
2688 ) -> WidgetId {
2689 crate::styles::RecipeSliderStyle::default().make_body(cfg, ctx)
2690 }
2691
2692 fn thumb_diameter(&self, _cfg: &SliderStyleConfig) -> f32 {
2693 self.0
2694 }
2695 }
2696
2697 let shipped = crate::styles::RecipeSliderStyle::default().thumb_diameter(&config());
2701 let knob = reported_knob(None);
2702 assert_eq!(
2703 (knob.width, knob.height),
2704 (shipped, shipped),
2705 "the default style reports a {shipped} dp knob and the region is {}x{}",
2706 knob.width,
2707 knob.height,
2708 );
2709
2710 let fat = reported_knob(Some(30.0));
2714 assert_eq!(
2715 (fat.width, fat.height),
2716 (30.0, 30.0),
2717 "a custom style's 30 dp knob reported as {}x{}",
2718 fat.width,
2719 fat.height,
2720 );
2721 assert_ne!(
2722 fat.width, shipped,
2723 "the probe only discriminates while the two styles disagree",
2724 );
2725 }
2726
2727 #[test]
2744 fn the_reported_body_is_the_whole_node() {
2745 use teksilo_core::partition::TargetRegion;
2746
2747 fn body_of(regions: &[TargetRegion]) -> &TargetRegion {
2748 regions
2749 .iter()
2750 .find(|r| r.part == SLIDER_PART_BODY)
2751 .expect("the slider reports its body as a press surface")
2752 }
2753
2754 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2755 let value = Signal::new(50.0_f32);
2756 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2757 tree.layout(SizeProposal::exact(200.0, 40.0));
2758
2759 let bounds = tree.bounds(s);
2760 let regions = tree.widget_target_regions(s);
2761 assert_eq!(regions.len(), 2, "body and knob, got {regions:?}");
2762
2763 let body = body_of(®ions);
2764 assert_eq!(
2765 body.rect, bounds,
2766 "the body region is the node's own rect, not a slice of it",
2767 );
2768 assert_eq!(
2769 body.role,
2770 teksilo_tokens::TargetRole::Target,
2771 "a press surface is a Target — a Grab would be audited against the \
2772 6 dp floor instead of the 24 dp one",
2773 );
2774
2775 let knob = regions
2778 .iter()
2779 .find(|r| r.part == SLIDER_PART_THUMB)
2780 .expect("the slider reports its knob")
2781 .rect;
2782 assert!(
2783 body.rect.width > knob.width,
2784 "the body ({:?}) should span the whole node the knob ({knob:?}) slides along",
2785 body.rect,
2786 );
2787 value.set(90.0);
2788 assert_eq!(
2789 body_of(&tree.widget_target_regions(s)).rect,
2790 bounds,
2791 "the body stays the whole node as the value moves",
2792 );
2793 }
2794
2795 #[test]
2798 fn the_reported_regions_clear_their_floors_at_compact() {
2799 let theme = teksilo_core::presets::intui::light();
2800 let tokens = theme.input;
2801 let mut tree = WidgetTree::new().with_theme(theme);
2802 let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
2803 tree.layout(SizeProposal::exact(200.0, 40.0));
2804 for region in tree.widget_target_regions(s) {
2805 let floor = match region.role {
2806 teksilo_tokens::TargetRole::Target => tokens.min_target_conformance,
2807 teksilo_tokens::TargetRole::Grab => tokens.grab_size,
2808 teksilo_tokens::TargetRole::Decoration => continue,
2809 };
2810 let min = region.rect.width.min(region.rect.height);
2811 assert!(
2812 min >= floor,
2813 "part {} ({:?}) measured {min}, under its {floor} dp floor",
2814 region.part,
2815 region.role,
2816 );
2817 }
2818 }
2819
2820 #[test]
2827 fn a_vertical_slider_reports_its_knob_where_it_paints_it() {
2828 use crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER;
2829
2830 let theme = teksilo_core::presets::intui::light();
2831 let accent = theme.colors.accent.to_array();
2832 let mut tree = WidgetTree::new().with_theme(theme);
2833 let s = tree
2834 .add(Slider::new(Signal::new(25.0_f32), 0.0, 100.0).orientation(Orientation::Vertical));
2835 tree.layout(SizeProposal::exact(60.0, 200.0));
2836 let painted = tree
2837 .render()
2838 .shapes
2839 .iter()
2840 .find(|q| {
2841 q.color == accent
2842 && (q.screen[2] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2843 && (q.screen[3] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2844 })
2845 .map(|q| q.screen[1] + q.screen[3] * 0.5)
2846 .expect("the body paints a knob");
2847 let reported = tree
2848 .widget_target_regions(s)
2849 .into_iter()
2850 .find(|r| r.part == SLIDER_PART_THUMB)
2851 .expect("the slider reports its knob")
2852 .rect
2853 .center()
2854 .y;
2855 let bounds = tree.bounds(s);
2856 assert!(
2857 painted > bounds.center().y,
2858 "a quarter-value knob is painted in the lower half ({painted})"
2859 );
2860 assert!(
2861 (reported - painted).abs() < 0.51,
2862 "the knob is reported at y {reported} and painted at y {painted}",
2863 );
2864 }
2865
2866 use crate::primitives::{FixedSize, HStack, VStack};
2871 use std::cell::RefCell;
2872 use teksilo_i18n::lit;
2873
2874 fn db(gain: f32) -> LocalizedString {
2876 lit!(format!("{gain:+.1} dB"))
2877 }
2878
2879 fn window() -> SizeProposal {
2881 SizeProposal::exact(400.0, 300.0)
2882 }
2883
2884 fn readout_tree() -> WidgetTree {
2887 WidgetTree::new()
2888 .with_theme(teksilo_core::presets::intui::light())
2889 .with_text_backend(Rc::new(
2890 RefCell::new(teksilo_canvas::MockTextBackend::new()),
2891 ))
2892 }
2893
2894 fn horizontal_scene(slider: Slider) -> (WidgetTree, WidgetId) {
2897 let mut tree = readout_tree();
2898 let s = tree.add(slider);
2899 let _column = tree.add(
2900 VStack::new()
2901 .child(FixedSize::new().height(100.0))
2902 .child(s)
2903 .child(FixedSize::new().height(100.0)),
2904 );
2905 tree.layout(window());
2906 (tree, s)
2907 }
2908
2909 fn away() -> Point {
2911 Point::new(200.0, 280.0)
2912 }
2913
2914 fn readout(tree: &WidgetTree) -> Option<(String, Rect)> {
2921 let overlays = tree.overlay_manager();
2922 overlays
2923 .active_content_ids()
2924 .into_iter()
2925 .find_map(|content| {
2926 let debug = tree.widget_debug_string(content)?;
2927 let text = debug
2928 .split("TooltipWidget { text: \"")
2929 .nth(1)?
2930 .split('"')
2931 .next()?
2932 .to_owned();
2933 let bounds = tree.overlay_content_bounds(overlays.find_by_content(content)?)?;
2934 Some((text, bounds))
2935 })
2936 }
2937
2938 fn knob(tree: &WidgetTree, s: WidgetId) -> Rect {
2940 tree.widget_target_regions(s)
2941 .into_iter()
2942 .find(|r| r.part == SLIDER_PART_THUMB)
2943 .expect("the slider reports its knob")
2944 .rect
2945 }
2946
2947 fn at_node<R>(
2949 tree: &mut WidgetTree,
2950 s: WidgetId,
2951 f: impl FnOnce(&teksilo_core::accesskit::Node) -> R,
2952 ) -> R {
2953 let update = tree.sync_accessibility();
2954 let nid = teksilo_core::accessibility::widget_id_to_node_id(s);
2955 let node = &update
2956 .nodes
2957 .iter()
2958 .find(|(id, _)| *id == nid)
2959 .expect("the slider publishes an AT node")
2960 .1;
2961 f(node)
2962 }
2963
2964 fn double_click(tree: &mut WidgetTree, at: Point) {
2966 tree.pointer_move(at);
2967 for _ in 0..2 {
2968 tree.pointer_down_button(at, PointerButton::Primary);
2969 tree.pointer_up_button(at, PointerButton::Primary);
2970 }
2971 }
2972
2973 fn click(tree: &mut WidgetTree, at: Point) {
2975 tree.pointer_move(at);
2976 tree.pointer_down_button(at, PointerButton::Primary);
2977 tree.pointer_up_button(at, PointerButton::Primary);
2978 }
2979
2980 #[test]
2981 fn the_readout_is_up_at_once_while_the_pointer_is_over_the_slider() {
2982 let (mut tree, s) =
2983 horizontal_scene(Slider::new(Signal::new(-3.0_f32), -12.0, 12.0).value_tooltip(db));
2984 assert_eq!(
2985 readout(&tree),
2986 None,
2987 "nothing is up before the pointer comes"
2988 );
2989
2990 tree.pointer_move(tree.bounds(s).center());
2991 tree.layout(window());
2992 let (text, _) = readout(&tree).expect("up with no hover delay");
2993 assert_eq!(text, "-3.0 dB");
2994 let listener = crate::common::heard_test::Listener::attach(&mut tree);
2997 assert!(
2998 !listener.finds(teksilo_core::accesskit::Role::Tooltip, "-3.0 dB"),
2999 "the readout is hidden from assistive technology",
3000 );
3001
3002 tree.pointer_move(away());
3003 tree.layout(window());
3004 assert_eq!(readout(&tree), None, "and gone when the pointer leaves");
3005 }
3006
3007 #[test]
3008 fn the_readout_follows_the_value_through_a_drag() {
3009 let value = Signal::new(0.0_f32);
3010 let (mut tree, s) = horizontal_scene(
3011 Slider::new(value.clone(), -12.0, 12.0)
3012 .step(0.5)
3013 .value_tooltip(db),
3014 );
3015 let bounds = tree.bounds(s);
3016 let start = knob(&tree, s).center();
3017 tree.pointer_move(start);
3018 tree.pointer_down_button(start, PointerButton::Primary);
3019 tree.layout(window());
3020 let surface = tree.overlay_manager().active_content_ids();
3021
3022 let mut last_x = f32::MIN;
3026 for dx in [10.0, 60.0, 120.0] {
3027 tree.pointer_move(Point::new(start.x + dx, bounds.bottom() + 40.0));
3028 tree.layout(window());
3029 let (text, rect) = readout(&tree).expect("up for the whole drag");
3030 assert_eq!(
3031 text,
3032 db(value.get()).resolve_now(),
3033 "the value as it stands"
3034 );
3035 let thumb = knob(&tree, s);
3036 assert!(
3037 (rect.center().x - thumb.center().x).abs() < 0.51,
3038 "centred on the thumb: {rect:?} over {thumb:?}",
3039 );
3040 assert!(rect.bottom() <= bounds.y, "above the slider: {rect:?}");
3041 assert!(rect.center().x >= last_x, "moving with the thumb");
3042 last_x = rect.center().x;
3043 }
3044 assert!(
3045 value.get() > 5.0,
3046 "the drag moved the value: {}",
3047 value.get()
3048 );
3049 assert_eq!(
3050 tree.overlay_manager().active_content_ids(),
3051 surface,
3052 "one surface, re-placed and re-worded, not a new one per value",
3053 );
3054
3055 let off = Point::new(start.x + 120.0, bounds.bottom() + 40.0);
3058 tree.pointer_up_button(off, PointerButton::Primary);
3059 tree.pointer_move(off);
3060 tree.layout(window());
3061 assert_eq!(
3062 readout(&tree),
3063 None,
3064 "the drag is over and the pointer is not on the slider"
3065 );
3066 }
3067
3068 #[test]
3069 fn the_readout_follows_the_arrows_under_keyboard_focus() {
3070 let value = Signal::new(0.0_f32);
3071 let mut tree = readout_tree();
3072 let s = tree.add(
3073 Slider::new(value.clone(), -12.0, 12.0)
3074 .step(1.0)
3075 .value_tooltip(db),
3076 );
3077 let next = tree.add(crate::button::Button::new(lit!("Next")));
3078 let _column = tree.add(
3079 VStack::new()
3080 .child(FixedSize::new().height(100.0))
3081 .child(s)
3082 .child(next),
3083 );
3084 tree.layout(window());
3085
3086 tree.press_key(Key::Tab, Modifiers::NONE);
3087 assert_eq!(tree.focused(), Some(s));
3088 tree.layout(window());
3089 let (text, mut at) = readout(&tree).expect("keyboard focus raises the readout");
3090 assert_eq!(text, "+0.0 dB");
3091
3092 for said in ["+1.0 dB", "+2.0 dB"] {
3093 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3094 tree.layout(window());
3095 let (text, rect) = readout(&tree).expect("still up");
3096 assert_eq!(text, said);
3097 assert!(
3098 rect.center().x > at.center().x,
3099 "it moved with the thumb: {rect:?} after {at:?}",
3100 );
3101 at = rect;
3102 }
3103
3104 tree.press_key(Key::Tab, Modifiers::NONE);
3105 assert_eq!(tree.focused(), Some(next));
3106 tree.layout(window());
3107 assert_eq!(readout(&tree), None, "focus leaving takes it down");
3108 }
3109
3110 #[test]
3111 fn a_clicked_slider_shows_its_readout_again_on_the_first_key() {
3112 let value = Signal::new(0.0_f32);
3116 let (mut tree, s) = horizontal_scene(
3117 Slider::new(value.clone(), -12.0, 12.0)
3118 .step(1.0)
3119 .value_tooltip(db),
3120 );
3121 let thumb = knob(&tree, s).center();
3122 tree.pointer_move(thumb);
3123 tree.pointer_down_button(thumb, PointerButton::Primary);
3124 tree.pointer_up_button(thumb, PointerButton::Primary);
3125 tree.pointer_move(away());
3126 tree.layout(window());
3127 assert_eq!(tree.focused(), Some(s), "the click focused the slider");
3128 assert_eq!(readout(&tree), None, "but not from the keyboard");
3129
3130 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3131 tree.layout(window());
3132 assert_eq!(
3133 readout(&tree).map(|(text, _)| text).as_deref(),
3134 Some("+1.0 dB")
3135 );
3136 }
3137
3138 #[test]
3139 fn escape_takes_the_readout_down_until_the_next_key() {
3140 let mut tree = readout_tree();
3143 let s = tree.add(
3144 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3145 .step(1.0)
3146 .value_tooltip(db),
3147 );
3148 tree.layout(window());
3149 tree.press_key(Key::Tab, Modifiers::NONE);
3150 tree.layout(window());
3151 assert!(readout(&tree).is_some());
3152
3153 tree.press_key(Key::Escape, Modifiers::NONE);
3154 tree.layout(window());
3155 assert_eq!(readout(&tree), None, "Escape dismisses it");
3156 assert_eq!(tree.focused(), Some(s), "and leaves focus where it was");
3157
3158 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3159 tree.layout(window());
3160 assert!(readout(&tree).is_some(), "the next key brings it back");
3161 }
3162
3163 #[test]
3164 fn a_horizontal_readout_goes_below_the_thumb_against_the_top_of_the_window() {
3165 let mut tree = readout_tree();
3166 let s = tree.add(Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db));
3167 let _column = tree.add(VStack::new().child(s).child(FixedSize::new().height(200.0)));
3168 tree.layout(window());
3169 let bounds = tree.bounds(s);
3170 assert!(bounds.y < 1.0, "the slider is at the top: {bounds:?}");
3171
3172 tree.pointer_move(bounds.center());
3173 tree.layout(window());
3174 let (_, rect) = readout(&tree).expect("up");
3175 assert!(
3176 rect.y >= bounds.bottom(),
3177 "no room above, so below the slider: {rect:?} under {bounds:?}",
3178 );
3179 }
3180
3181 #[test]
3182 fn a_vertical_readout_sits_beside_the_slider_and_rides_its_thumb() {
3183 let value = Signal::new(0.0_f32);
3184 let mut tree = readout_tree();
3185 let s = tree.add(
3186 Slider::new(value.clone(), -12.0, 12.0)
3187 .step(1.0)
3188 .orientation(Orientation::Vertical)
3189 .value_tooltip(db),
3190 );
3191 let _row = tree.add(
3192 HStack::new()
3193 .child(FixedSize::new().width(150.0))
3194 .child(FixedSize::new().height(200.0).child(s)),
3195 );
3196 tree.layout(window());
3197 let bounds = tree.bounds(s);
3198
3199 tree.pointer_move(knob(&tree, s).center());
3200 tree.layout(window());
3201 let (_, low) = readout(&tree).expect("up");
3202 assert!(
3203 low.right() <= bounds.x,
3204 "on the inline-start side, clear of the slider: {low:?} beside {bounds:?}",
3205 );
3206 let thumb = knob(&tree, s);
3207 assert!(
3208 low.y < thumb.bottom() + 8.0 && low.bottom() > thumb.y,
3209 "level with the thumb rather than somewhere along the track: {low:?} by {thumb:?}",
3210 );
3211
3212 tree.focus(s);
3214 tree.press_key(Key::ArrowUp, Modifiers::NONE);
3215 tree.layout(window());
3216 let (_, high) = readout(&tree).expect("still up");
3217 assert!(
3218 high.y < low.y,
3219 "a higher value raises the thumb, and the readout with it: {high:?} after {low:?}",
3220 );
3221 }
3222
3223 #[test]
3224 fn the_readout_text_is_the_accessible_value() {
3225 use crate::common::heard_test::{Heard, Listener};
3226
3227 let value = Signal::new(-3.0_f32);
3228 let mut tree = WidgetTree::new();
3229 let s = tree.add(
3230 Slider::new(value.clone(), -12.0, 12.0)
3231 .step(0.5)
3232 .label(lit!("Low shelf"))
3233 .value_tooltip(db),
3234 );
3235 let p = SizeProposal::exact(200.0, 60.0);
3236 tree.layout(p);
3237 assert_eq!(
3238 at_node(&mut tree, s, |n| (
3239 n.value().map(str::to_owned),
3240 n.numeric_value()
3241 )),
3242 (Some("-3.0 dB".to_owned()), Some(-3.0)),
3243 "the text beside the number",
3244 );
3245
3246 tree.focus(s);
3247 tree.layout(p);
3248 let mut listener = Listener::attach(&mut tree);
3249 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3250 tree.layout(p);
3251 assert_eq!(
3252 listener.heard(&mut tree),
3253 vec![Heard::FocusValue("-2.5 dB".to_owned())],
3254 );
3255 }
3256
3257 #[test]
3258 fn a_double_click_resets_to_the_default_and_reports_it() {
3259 let theme = teksilo_core::presets::intui::light();
3263 let mouse = theme.input.profile(teksilo_tokens::PointerKind::Mouse);
3264 let (interval, slop) = (mouse.multi_tap_interval, mouse.multi_tap_slop);
3265 let scene = || {
3266 let value = Signal::new(6.0_f32);
3267 let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3268 let sink = seen.clone();
3269 let (tree, s) = horizontal_scene(
3270 Slider::new(value.clone(), -12.0, 12.0)
3271 .default_value(0.0)
3272 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3273 );
3274 let bounds = tree.bounds(s);
3275 let first = Point::new(bounds.x + bounds.width * 0.8, bounds.center().y);
3276 (tree, value, seen, first)
3277 };
3278
3279 let (mut tree, value, seen, first) = scene();
3282 click(&mut tree, first);
3283 tree.advance_time(interval / 2);
3284 click(&mut tree, Point::new(first.x + slop / 2.0, first.y));
3285 assert_eq!(value.get(), 0.0, "the double-click put the default back");
3286 let reported = seen.borrow().clone();
3287 assert_eq!(
3288 reported.len(),
3289 2,
3290 "the first click's jump, then the reset: {reported:?}"
3291 );
3292 assert_eq!(
3293 reported.last(),
3294 Some(&0.0),
3295 "the reset is reported, and last"
3296 );
3297 tree.assert_no_leaked_pointer_state();
3299
3300 let (mut tree, value, seen, first) = scene();
3303 click(&mut tree, first);
3304 let jumped = value.get();
3305 tree.advance_time(interval + std::time::Duration::from_millis(1));
3306 click(&mut tree, first);
3307 assert_eq!(value.get(), jumped, "two slow clicks are two clicks");
3308 assert_eq!(seen.borrow().len(), 1, "and nothing was reset");
3309
3310 let (mut tree, value, seen, first) = scene();
3312 click(&mut tree, first);
3313 let jumped = value.get();
3314 tree.advance_time(interval / 2);
3315 click(&mut tree, Point::new(first.x - slop * 4.0, first.y));
3316 assert!(
3317 value.get() < jumped && value.get() != 0.0,
3318 "a click outside the slop jumps rather than resets: {} after {jumped}",
3319 value.get()
3320 );
3321 assert!(
3322 seen.borrow().iter().all(|v| *v != 0.0),
3323 "and nothing was reset: {:?}",
3324 seen.borrow()
3325 );
3326 }
3327
3328 #[test]
3329 fn an_applications_custom_action_keeps_the_reset_beside_it() {
3330 use teksilo_core::accesskit::{Action, ActionData};
3331 use teksilo_core::widget_builder::WidgetBuilder;
3332
3333 let value = Signal::new(6.0_f32);
3334 let pinned = Rc::new(Cell::new(0_u32));
3335 let sink = pinned.clone();
3336 let mut tree = WidgetTree::new();
3337 let s = tree.add(
3338 Slider::new(value.clone(), -12.0, 12.0)
3339 .default_value(0.0)
3340 .access_custom_action(lit!("Pin band"), move |_ctx| sink.set(sink.get() + 1)),
3341 );
3342 tree.layout(SizeProposal::exact(200.0, 60.0));
3343 let offered = at_node(&mut tree, s, |n| {
3344 n.custom_actions()
3345 .iter()
3346 .map(|a| (a.id, a.description.clone()))
3347 .collect::<Vec<_>>()
3348 });
3349 assert_eq!(
3350 offered,
3351 vec![
3352 (RESET_ACTION_ID, "Reset to default".to_owned()),
3353 (0, "Pin band".to_owned()),
3354 ],
3355 );
3356
3357 assert!(access(
3358 &mut tree,
3359 s,
3360 Action::CustomAction,
3361 Some(ActionData::CustomAction(RESET_ACTION_ID)),
3362 ));
3363 assert_eq!(
3364 (value.get(), pinned.get()),
3365 (0.0, 0),
3366 "the reset is the slider's"
3367 );
3368 value.set(6.0);
3369 assert!(access(
3370 &mut tree,
3371 s,
3372 Action::CustomAction,
3373 Some(ActionData::CustomAction(0)),
3374 ));
3375 assert_eq!(
3376 (value.get(), pinned.get()),
3377 (6.0, 1),
3378 "and the application's is its own"
3379 );
3380 }
3381
3382 #[test]
3383 fn the_reset_action_resets_and_reports() {
3384 use teksilo_core::accesskit::{Action, ActionData};
3385
3386 let value = Signal::new(6.0_f32);
3387 let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3388 let sink = seen.clone();
3389 let mut tree = WidgetTree::new();
3390 let s = tree.add(
3391 Slider::new(value.clone(), -12.0, 12.0)
3392 .default_value(0.0)
3393 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3394 );
3395 tree.layout(SizeProposal::exact(200.0, 60.0));
3396
3397 let (advertised, offered) = at_node(&mut tree, s, |n| {
3398 (
3399 n.supports_action(Action::CustomAction),
3400 n.custom_actions()
3401 .iter()
3402 .map(|a| (a.id, a.description.clone()))
3403 .collect::<Vec<_>>(),
3404 )
3405 });
3406 assert!(
3407 advertised,
3408 "the custom action is reachable only when advertised"
3409 );
3410 assert_eq!(
3411 offered,
3412 vec![(RESET_ACTION_ID, "Reset to default".to_owned())]
3413 );
3414
3415 assert!(access(
3416 &mut tree,
3417 s,
3418 Action::CustomAction,
3419 Some(ActionData::CustomAction(RESET_ACTION_ID)),
3420 ));
3421 assert_eq!(value.get(), 0.0);
3422 assert_eq!(*seen.borrow(), vec![0.0], "reported as a user change");
3423 }
3424
3425 #[test]
3426 fn the_reset_action_is_named_in_the_users_language() {
3427 use crate::common::locale_switch_test::speaking;
3430
3431 let (manager, mut tree) = speaking("en-US");
3432 let s = tree.add(Slider::new(Signal::new(6.0_f32), -12.0, 12.0).default_value(0.0));
3433 let p = SizeProposal::exact(200.0, 60.0);
3434 tree.layout(p);
3435 let name = |tree: &mut WidgetTree| {
3436 at_node(tree, s, |n| {
3437 n.custom_actions()
3438 .iter()
3439 .find(|a| a.id == RESET_ACTION_ID)
3440 .map(|a| a.description.clone())
3441 })
3442 };
3443 assert_eq!(name(&mut tree).as_deref(), Some("Reset to default"));
3444
3445 manager.set_locale("fr-FR".parse().unwrap());
3446 tree.set_locale("fr-FR".to_string());
3447 tree.layout(p);
3448 assert_eq!(
3449 name(&mut tree).as_deref(),
3450 Some("Rétablir la valeur par défaut")
3451 );
3452 teksilo_i18n::thread_local::clear();
3453 }
3454
3455 #[test]
3456 fn a_disabled_slider_neither_resets_nor_shows_a_readout() {
3457 use teksilo_core::accesskit::{Action, ActionData};
3458
3459 let value = Signal::new(6.0_f32);
3460 let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3461 let sink = seen.clone();
3462 let (mut tree, s) = horizontal_scene(
3463 Slider::new(value.clone(), -12.0, 12.0)
3464 .enabled(false)
3465 .default_value(0.0)
3466 .value_tooltip(db)
3467 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3468 );
3469 let bounds = tree.bounds(s);
3470
3471 tree.pointer_move(bounds.center());
3472 tree.layout(window());
3473 assert_eq!(readout(&tree), None, "no readout over a disabled slider");
3474
3475 double_click(
3476 &mut tree,
3477 Point::new(bounds.x + bounds.width * 0.8, bounds.center().y),
3478 );
3479 let _ = access(
3480 &mut tree,
3481 s,
3482 Action::CustomAction,
3483 Some(ActionData::CustomAction(RESET_ACTION_ID)),
3484 );
3485 assert_eq!(
3486 value.get(),
3487 6.0,
3488 "neither the double-click nor the action reset it"
3489 );
3490 assert!(seen.borrow().is_empty(), "and nothing was reported");
3491 }
3492
3493 #[test]
3494 fn disabling_a_slider_under_the_pointer_takes_its_readout_down() {
3495 let enabled = Signal::new(true);
3498 let (mut tree, s) = horizontal_scene(
3499 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3500 .enabled(enabled.clone())
3501 .value_tooltip(db),
3502 );
3503 tree.pointer_move(tree.bounds(s).center());
3504 tree.layout(window());
3505 assert!(readout(&tree).is_some());
3506
3507 enabled.set(false);
3508 tree.layout(window());
3509 tree.layout(window());
3510 assert_eq!(readout(&tree), None, "disabled under the pointer");
3511 assert!(
3512 tree.active_overlays().is_empty(),
3513 "with no overlay left behind"
3514 );
3515
3516 enabled.set(true);
3517 tree.layout(window());
3518 assert!(
3519 tree.active_overlays().is_empty(),
3520 "re-enabling raises nothing until the next hover, drag or key",
3521 );
3522 }
3523
3524 #[test]
3532 fn a_cancelled_touch_drag_leaves_no_readout() {
3533 let value = Signal::new(0.0_f32);
3535 let (mut tree, s) =
3536 horizontal_scene(Slider::new(value.clone(), -12.0, 12.0).value_tooltip(db));
3537 let start = knob(&tree, s).center();
3538 let finger = tree.new_contact();
3539 tree.touch_down(finger, start);
3540 for dx in [30.0, 60.0] {
3541 tree.touch_move(finger, Point::new(start.x + dx, start.y));
3542 }
3543 tree.layout(window());
3544 assert!(
3545 readout(&tree).is_some(),
3546 "a finger's drag shows the readout"
3547 );
3548
3549 tree.touch_cancel(finger, Point::new(start.x + 60.0, start.y));
3550 tree.layout(window());
3551 assert_eq!(readout(&tree), None, "and a cancel ends the drag");
3552 tree.assert_no_leaked_pointer_state();
3553 }
3554
3555 #[test]
3556 fn a_slider_without_a_readout_or_a_default_is_as_it_was() {
3557 let value = Signal::new(50.0_f32);
3558 let (mut tree, s) = horizontal_scene(Slider::new(value.clone(), 0.0, 100.0));
3559 let bounds = tree.bounds(s);
3560
3561 tree.pointer_move(bounds.center());
3562 tree.focus(s);
3563 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3564 tree.layout(window());
3565 assert!(
3566 tree.active_overlays().is_empty(),
3567 "no overlay on hover or key"
3568 );
3569 let (text, custom) = at_node(&mut tree, s, |n| {
3570 (
3571 n.value().map(str::to_owned),
3572 n.supports_action(teksilo_core::accesskit::Action::CustomAction),
3573 )
3574 });
3575 assert_eq!(text, None, "no value text: a reader hears the number");
3576 assert!(!custom, "and no reset action");
3577
3578 double_click(
3581 &mut tree,
3582 Point::new(bounds.x + bounds.width * 0.75, bounds.center().y),
3583 );
3584 assert!(
3585 (value.get() - 75.0).abs() < 2.0,
3586 "two clicks at three quarters leave the value there: {}",
3587 value.get()
3588 );
3589 }
3590
3591 fn between(
3598 above: WidgetId,
3599 slider: Slider,
3600 below: WidgetId,
3601 tree: &mut WidgetTree,
3602 ) -> WidgetId {
3603 let s = tree.add(slider);
3604 let _column = tree.add(
3605 VStack::new()
3606 .child(FixedSize::new().height(100.0))
3607 .child(above)
3608 .child(s)
3609 .child(below)
3610 .child(FixedSize::new().height(60.0)),
3611 );
3612 tree.layout(window());
3613 s
3614 }
3615
3616 #[test]
3617 fn escape_leaves_focus_on_the_slider_the_click_focused() {
3618 let mut tree = readout_tree();
3621 let field = tree.add(crate::button::Button::new(lit!("Field")));
3622 let next = tree.add(crate::button::Button::new(lit!("Next")));
3623 let s = between(
3624 field,
3625 Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db),
3626 next,
3627 &mut tree,
3628 );
3629 tree.focus(field);
3630
3631 let thumb = knob(&tree, s).center();
3632 tree.pointer_move(thumb);
3633 tree.layout(window());
3634 assert!(readout(&tree).is_some(), "the pointer raised it");
3635 tree.pointer_down_button(thumb, PointerButton::Primary);
3636 tree.pointer_up_button(thumb, PointerButton::Primary);
3637 tree.layout(window());
3638 assert_eq!(tree.focused(), Some(s), "the click focused the slider");
3639 assert!(readout(&tree).is_some(), "and the pointer keeps it up");
3640
3641 tree.press_key(Key::Escape, Modifiers::NONE);
3642 tree.layout(window());
3643 assert_eq!(readout(&tree), None, "Escape took it down");
3644 assert_eq!(
3645 tree.focused(),
3646 Some(s),
3647 "and left focus where the click put it"
3648 );
3649 }
3650
3651 #[test]
3652 fn a_press_and_a_hover_on_the_readout_reach_what_is_under_it() {
3653 use teksilo_core::widget_builder::WidgetBuilder;
3654
3655 let taps = Rc::new(Cell::new(0_u32));
3656 let count = taps.clone();
3657 let mut tree = readout_tree();
3658 let under = tree.add(
3659 FixedSize::new()
3660 .width(400.0)
3661 .height(40.0)
3662 .on_tap(move |_e, _c| count.set(count.get() + 1)),
3663 );
3664 let below = tree.add(FixedSize::new().height(10.0));
3665 let s = between(
3666 under,
3667 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3668 .step(1.0)
3669 .value_tooltip(db),
3670 below,
3671 &mut tree,
3672 );
3673 tree.focus(s);
3674 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3675 tree.layout(window());
3676 let (_, bubble) = readout(&tree).expect("up from the keyboard");
3677 let at = bubble.center();
3678 assert!(
3679 tree.bounds(under).contains(at),
3680 "fixture: the bubble hangs over the target, {bubble:?} over {:?}",
3681 tree.bounds(under),
3682 );
3683
3684 tree.pointer_move(at);
3685 assert_eq!(tree.hovered(), Some(under), "the hover is the target's");
3686 tree.pointer_down_button(at, PointerButton::Primary);
3687 tree.pointer_up_button(at, PointerButton::Primary);
3688 assert_eq!(taps.get(), 1, "and so is the press");
3689 }
3690
3691 #[test]
3692 fn a_press_on_the_readout_still_closes_a_popover_it_is_outside_of() {
3693 use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
3694
3695 let mut tree = readout_tree();
3696 let above = tree.add(FixedSize::new().height(40.0));
3697 let trigger = tree.add(FixedSize::new().width(60.0).height(20.0));
3698 let s = between(
3699 above,
3700 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3701 .step(1.0)
3702 .value_tooltip(db),
3703 trigger,
3704 &mut tree,
3705 );
3706 let panel = tree.add(FixedSize::new().width(120.0).height(30.0));
3709 tree.show_overlay(OverlayRequest::new(
3710 panel,
3711 trigger,
3712 OverlayPlacement::Below,
3713 DismissBehavior::ClickOutside,
3714 ));
3715 tree.layout(window());
3716 tree.focus(s);
3718 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3719 tree.layout(window());
3720 let (_, bubble) = readout(&tree).expect("up from the keyboard");
3721 let popover = tree
3722 .overlay_manager()
3723 .find_by_content(panel)
3724 .expect("the popover is up");
3725 let popover_bounds = tree.overlay_content_bounds(popover).expect("and laid out");
3726 assert!(
3727 !popover_bounds.contains(bubble.center()),
3728 "fixture: the press lands outside the popover"
3729 );
3730
3731 tree.pointer_down_button(bubble.center(), PointerButton::Primary);
3732 tree.pointer_up_button(bubble.center(), PointerButton::Primary);
3733 tree.layout(window());
3734 assert_eq!(
3735 tree.overlay_manager().find_by_content(panel),
3736 None,
3737 "a press outside the popover closes it, bubble or no bubble",
3738 );
3739 }
3740
3741 #[test]
3742 fn a_readout_raised_by_tab_follows_the_scroll_that_reveals_the_slider() {
3743 let mut tree = readout_tree();
3744 let s = tree.add(Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db));
3745 let page = tree.add(
3746 VStack::new()
3747 .child(FixedSize::new().height(600.0))
3748 .child(s)
3749 .child(FixedSize::new().height(60.0)),
3750 );
3751 let _scroller = tree.add(crate::scroll_area::ScrollArea::new().child(page));
3752 tree.layout(window());
3753 assert!(
3754 tree.bounds(s).y > 300.0,
3755 "fixture: the slider starts below the fold"
3756 );
3757
3758 tree.press_key(Key::Tab, Modifiers::NONE);
3759 assert_eq!(tree.focused(), Some(s));
3760 tree.layout(window());
3761 tree.layout(window());
3762 let slider = tree.bounds(s);
3763 assert!(
3764 slider.bottom() <= 300.0,
3765 "fixture: Tab scrolled it into view, {slider:?}"
3766 );
3767 let (_, bubble) = readout(&tree).expect("Tab raised it");
3768 let thumb = knob(&tree, s);
3769 assert!(
3770 (bubble.center().x - thumb.center().x).abs() < 0.51,
3771 "centred on the thumb: {bubble:?} over {thumb:?}",
3772 );
3773 assert!(
3774 bubble.bottom() <= slider.y && bubble.bottom() > slider.y - 24.0,
3775 "just above the slider where the scroll put it: {bubble:?} over {slider:?}",
3776 );
3777 }
3778
3779 #[test]
3780 fn the_readout_follows_the_thumb_when_the_window_resizes() {
3781 let value = Signal::new(6.0_f32);
3782 let (mut tree, s) = horizontal_scene(
3783 Slider::new(value.clone(), -12.0, 12.0)
3784 .step(1.0)
3785 .value_tooltip(db),
3786 );
3787 tree.focus(s);
3788 tree.press_key(Key::ArrowRight, Modifiers::NONE);
3789 tree.layout(window());
3790 let (_, wide) = readout(&tree).expect("up from the keyboard");
3791
3792 let narrow = SizeProposal::exact(250.0, 300.0);
3793 tree.layout(narrow);
3794 tree.layout(narrow);
3795 let (_, after) = readout(&tree).expect("still up");
3796 let thumb = knob(&tree, s);
3797 assert!(
3798 after.center().x < wide.center().x - 50.0,
3799 "fixture: the thumb moved with the width, {after:?} after {wide:?}"
3800 );
3801 assert!(
3802 (after.center().x - thumb.center().x).abs() < 0.51,
3803 "centred on the thumb where it is now: {after:?} over {thumb:?}",
3804 );
3805
3806 value.set(-6.0);
3808 tree.layout(narrow);
3809 let (text, written) = readout(&tree).expect("still up");
3810 let thumb = knob(&tree, s);
3811 assert_eq!(text, "-6.0 dB");
3812 assert!(
3813 (written.center().x - thumb.center().x).abs() < 0.51,
3814 "over the thumb the write moved: {written:?} over {thumb:?}",
3815 );
3816 }
3817
3818 #[test]
3819 fn a_hover_the_slider_was_never_told_ended_does_not_hold_its_readout() {
3820 use teksilo_core::accesskit::Action;
3821
3822 let enabled = Signal::new(true);
3823 let mut tree = readout_tree();
3824 let above = tree.add(FixedSize::new().height(10.0));
3825 let next = tree.add(crate::button::Button::new(lit!("Next")));
3826 let s = between(
3827 above,
3828 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3829 .step(1.0)
3830 .enabled(enabled.clone())
3831 .value_tooltip(db),
3832 next,
3833 &mut tree,
3834 );
3835 tree.pointer_move(tree.bounds(s).center());
3836 tree.layout(window());
3837 assert!(readout(&tree).is_some());
3838
3839 enabled.set(false);
3842 tree.layout(window());
3843 tree.pointer_move(away());
3844 enabled.set(true);
3845 tree.layout(window());
3846 tree.layout(window());
3847 assert_eq!(readout(&tree), None, "nothing is hovering it");
3848
3849 assert!(access(&mut tree, s, Action::Increment, None));
3850 tree.layout(window());
3851 assert_eq!(
3852 readout(&tree),
3853 None,
3854 "an assistive step on a slider nobody is hovering raises nothing",
3855 );
3856
3857 tree.press_key(Key::Tab, Modifiers::NONE);
3858 assert_eq!(tree.focused(), Some(s));
3859 tree.layout(window());
3860 assert!(readout(&tree).is_some(), "keyboard focus raises it");
3861 tree.press_key(Key::Tab, Modifiers::NONE);
3862 assert_eq!(tree.focused(), Some(next));
3863 tree.layout(window());
3864 assert_eq!(
3865 readout(&tree),
3866 None,
3867 "and with focus gone and nothing hovering it, it goes",
3868 );
3869 }
3870
3871 #[test]
3872 fn escape_takes_a_hover_readout_down_and_still_reaches_the_focused_field() {
3873 use teksilo_core::event::EventResponse;
3876 use teksilo_core::widget_builder::WidgetBuilder;
3877
3878 let heard = Rc::new(Cell::new(0_u32));
3879 let sink = heard.clone();
3880 let mut tree = readout_tree();
3881 let above = tree.add(FixedSize::new().height(10.0));
3882 let field = tree.add(
3883 FixedSize::new()
3884 .width(100.0)
3885 .height(24.0)
3886 .focusable(true)
3887 .on_key(move |event, _ctx| match event {
3888 WidgetEvent::KeyDown {
3889 key: Key::Escape, ..
3890 } => {
3891 sink.set(sink.get() + 1);
3892 EventResponse::Handled
3893 }
3894 _ => EventResponse::Ignored,
3895 }),
3896 );
3897 let s = between(
3898 above,
3899 Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db),
3900 field,
3901 &mut tree,
3902 );
3903 tree.focus(field);
3904 tree.pointer_move(knob(&tree, s).center());
3905 tree.layout(window());
3906 assert!(readout(&tree).is_some(), "the pointer raised it");
3907
3908 tree.press_key(Key::Escape, Modifiers::NONE);
3909 tree.layout(window());
3910 assert_eq!(readout(&tree), None, "Escape took the readout down");
3911 assert_eq!(heard.get(), 1, "and the field heard the Escape too");
3912 assert_eq!(tree.focused(), Some(field), "with focus left alone");
3913 }
3914
3915 #[test]
3916 fn clicks_after_the_second_in_a_burst_leave_the_default_alone() {
3917 let value = Signal::new(6.0_f32);
3918 let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3919 let sink = seen.clone();
3920 let (mut tree, s) = horizontal_scene(
3921 Slider::new(value.clone(), -12.0, 12.0)
3922 .default_value(0.0)
3923 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3924 );
3925 let bounds = tree.bounds(s);
3926 let at = Point::new(bounds.x + bounds.width * 0.8, bounds.center().y);
3927 tree.pointer_move(at);
3928 for click in 1..=5 {
3932 tree.pointer_down_button(at, PointerButton::Primary);
3933 tree.pointer_up_button(at, PointerButton::Primary);
3934 if click >= 2 {
3935 assert_eq!(
3936 value.get(),
3937 0.0,
3938 "click {click} of the burst left the default where it was"
3939 );
3940 }
3941 tree.advance_time(std::time::Duration::from_millis(100));
3942 }
3943 let seen = seen.borrow().clone();
3944 assert_eq!(seen.len(), 2, "one jump and one reset: {seen:?}");
3945 assert_eq!(seen[1], 0.0);
3946 }
3947
3948 #[test]
3949 fn focus_leaving_a_hovered_slider_keeps_its_readout_in_both_orientations() {
3950 for orientation in [Orientation::Horizontal, Orientation::Vertical] {
3951 let mut tree = readout_tree();
3952 let s = tree.add(
3953 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3954 .orientation(orientation)
3955 .value_tooltip(db),
3956 );
3957 let next = tree.add(crate::button::Button::new(lit!("Next")));
3958 let sized = match orientation {
3959 Orientation::Horizontal => FixedSize::new().width(160.0).child(s),
3960 Orientation::Vertical => FixedSize::new().height(150.0).child(s),
3961 };
3962 let _root = tree.add(
3963 VStack::new().child(FixedSize::new().height(80.0)).child(
3964 HStack::new()
3965 .child(FixedSize::new().width(120.0))
3966 .child(sized)
3967 .child(next),
3968 ),
3969 );
3970 tree.layout(window());
3971 tree.press_key(Key::Tab, Modifiers::NONE);
3972 assert_eq!(tree.focused(), Some(s));
3973 tree.pointer_move(knob(&tree, s).center());
3974 tree.layout(window());
3975 assert!(readout(&tree).is_some(), "{orientation:?}: up");
3976
3977 tree.press_key(Key::Tab, Modifiers::NONE);
3978 assert_eq!(tree.focused(), Some(next));
3979 tree.layout(window());
3980 assert!(
3981 readout(&tree).is_some(),
3982 "{orientation:?}: focus left, but the pointer is still on it",
3983 );
3984 }
3985 }
3986
3987 #[test]
3988 fn another_contacts_cancel_leaves_a_live_drag_running() {
3989 let value = Signal::new(0.0_f32);
3990 let (mut tree, s) = horizontal_scene(Slider::new(value.clone(), 0.0, 100.0));
3991 let bounds = tree.bounds(s);
3992 let start = Point::new(bounds.x + 40.0, bounds.center().y);
3993 let dragging = tree.new_contact();
3994 tree.touch_down(dragging, start);
3995 for dx in [30.0, 60.0] {
3996 tree.touch_move(dragging, Point::new(start.x + dx, start.y));
3997 }
3998 let held = value.get();
3999 assert!(held > 0.0, "fixture: the drag is live");
4000
4001 let other = tree.new_contact();
4002 let elsewhere = Point::new(bounds.x + 300.0, bounds.center().y);
4003 tree.touch_down(other, elsewhere);
4004 tree.touch_cancel(other, elsewhere);
4005
4006 tree.touch_move(dragging, Point::new(start.x + 150.0, start.y));
4007 assert!(
4008 value.get() > held,
4009 "the first finger's drag carried on: {} after {held}",
4010 value.get()
4011 );
4012 tree.touch_up(dragging, Point::new(start.x + 150.0, start.y));
4013 tree.assert_no_leaked_pointer_state();
4014 }
4015
4016 #[test]
4017 fn a_disabled_slider_does_not_offer_its_reset() {
4018 use teksilo_core::accesskit::Action;
4019
4020 let enabled = Signal::new(true);
4021 let mut tree = WidgetTree::new();
4022 let s = tree.add(
4023 Slider::new(Signal::new(6.0_f32), -12.0, 12.0)
4024 .enabled(enabled.clone())
4025 .default_value(0.0),
4026 );
4027 let p = SizeProposal::exact(200.0, 60.0);
4028 let offered = |tree: &mut WidgetTree| {
4029 at_node(tree, s, |n| {
4030 (
4031 n.supports_action(Action::CustomAction),
4032 n.custom_actions().iter().any(|a| a.id == RESET_ACTION_ID),
4033 )
4034 })
4035 };
4036 tree.layout(p);
4037 assert_eq!(offered(&mut tree), (true, true));
4038
4039 enabled.set(false);
4040 tree.layout(p);
4041 assert_eq!(
4042 offered(&mut tree),
4043 (false, false),
4044 "an action that would do nothing is not offered"
4045 );
4046
4047 enabled.set(true);
4048 tree.layout(p);
4049 assert_eq!(
4050 offered(&mut tree),
4051 (true, true),
4052 "and comes back with the slider"
4053 );
4054 }
4055
4056 fn tap_mask_scene(
4063 build: impl FnOnce(Slider) -> Box<dyn Widget>,
4064 ) -> (WidgetTree, Signal<f32>, Point, Point) {
4065 let value = Signal::new(5.0_f32);
4066 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4067 let s = tree.add_boxed(build(Slider::new(value.clone(), 0.0, 10.0)));
4068 let _row = tree.add(FixedSize::new().width(200.0).height(40.0).child(s));
4069 tree.layout(SizeProposal::exact(400.0, 100.0));
4070 let b = tree.bounds(s);
4071 let at = |f: f32| Point::new(b.x + b.width * f, b.center().y);
4072 (tree, value, at(0.25), at(0.75))
4073 }
4074
4075 fn click_with(tree: &mut WidgetTree, at: Point, button: PointerButton) {
4078 tree.pointer_move(at);
4079 tree.pointer_down_button(at, button);
4080 tree.pointer_up_button(at, button);
4081 tree.advance_time(std::time::Duration::from_secs(1));
4082 }
4083
4084 #[test]
4089 fn an_applications_secondary_tap_does_not_retarget_the_track_click() {
4090 use teksilo_core::event::ButtonMask;
4091 use teksilo_core::widget_builder::WidgetBuilder;
4092
4093 let app = Rc::new(Cell::new(0_u32));
4094 let count = app.clone();
4095 let (mut tree, value, quarter, three_quarters) = tap_mask_scene(move |s| {
4096 Box::new(
4097 s.on_tap(move |_, _| count.set(count.get() + 1))
4098 .accept_tap_buttons(ButtonMask::SECONDARY),
4099 )
4100 });
4101
4102 click_with(&mut tree, three_quarters, PointerButton::Primary);
4103 let jumped = value.get();
4104 assert!(jumped > 6.0, "a primary click still jumps: {jumped}");
4105 assert_eq!(app.get(), 0, "and is not the application's");
4106
4107 click_with(&mut tree, quarter, PointerButton::Secondary);
4108 assert_eq!(value.get(), jumped, "a secondary click does not jump");
4109 assert_eq!(app.get(), 1, "it is the application's");
4110 }
4111
4112 #[test]
4116 fn the_track_click_is_the_primary_buttons_whatever_mask_reaches_it() {
4117 use teksilo_core::event::ButtonMask;
4118 use teksilo_core::widget_builder::WidgetBuilder;
4119
4120 let (mut tree, value, quarter, three_quarters) = tap_mask_scene(|s| {
4121 Box::new(s.accept_tap_buttons(ButtonMask::PRIMARY | ButtonMask::SECONDARY))
4122 });
4123 click_with(&mut tree, quarter, PointerButton::Secondary);
4124 assert_eq!(value.get(), 5.0, "a secondary click does not jump");
4125 click_with(&mut tree, three_quarters, PointerButton::Primary);
4126 assert!(value.get() > 6.0, "a primary one does: {}", value.get());
4127 }
4128
4129 fn press_scene(jump: bool) -> (WidgetTree, WidgetId, Signal<f32>, Rc<RefCell<Vec<f32>>>) {
4132 let value = Signal::new(0.0_f32);
4133 let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
4134 let sink = seen.clone();
4135 let (tree, s) = horizontal_scene(
4136 Slider::new(value.clone(), 0.0, 100.0)
4137 .jump_on_press(jump)
4138 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
4139 );
4140 (tree, s, value, seen)
4141 }
4142
4143 fn along(tree: &WidgetTree, s: WidgetId, f: f32) -> Point {
4145 let b = tree.bounds(s);
4146 let r = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER * 0.5;
4147 Point::new(b.x + r + (b.width - 2.0 * r) * f, b.center().y)
4148 }
4149
4150 #[test]
4151 fn jump_on_press_moves_the_value_on_a_mouse_press() {
4152 let (mut tree, s, value, seen) = press_scene(true);
4153 let at = along(&tree, s, 0.75);
4154 tree.pointer_move(at);
4155 tree.pointer_down_button(at, PointerButton::Primary);
4156 assert!(
4157 (value.get() - 75.0).abs() < 1.01,
4158 "the press moved it: {}",
4159 value.get()
4160 );
4161 assert_eq!(seen.borrow().len(), 1, "and reported it as a user change");
4162
4163 let release = Point::new(at.x + 4.0, at.y);
4166 tree.pointer_move(release);
4167 tree.pointer_up_button(release, PointerButton::Primary);
4168 assert_eq!(
4169 seen.borrow().len(),
4170 1,
4171 "the release adds nothing: {:?}",
4172 seen.borrow()
4173 );
4174 tree.assert_no_leaked_pointer_state();
4175 }
4176
4177 #[test]
4180 fn a_drag_after_a_jumping_press_carries_on_without_a_jump() {
4181 let (mut tree, s, value, seen) = press_scene(true);
4182 let start = along(&tree, s, 0.25);
4183 tree.pointer_move(start);
4184 tree.pointer_down_button(start, PointerButton::Primary);
4185 let pressed = value.get();
4186 assert!((pressed - 25.0).abs() < 1.01, "pressed to {pressed}");
4187
4188 tree.pointer_move(Point::new(start.x + 2.0, start.y));
4190 tree.pointer_move(Point::new(start.x + 8.0, start.y));
4191 assert_eq!(
4192 *seen.borrow(),
4193 vec![pressed],
4194 "crossing the slop does not move the value again"
4195 );
4196
4197 let end = along(&tree, s, 0.6);
4198 tree.pointer_move(end);
4199 tree.pointer_up_button(end, PointerButton::Primary);
4200 assert!(
4201 (value.get() - 60.0).abs() < 1.01,
4202 "the drag moved it on: {}",
4203 value.get()
4204 );
4205 tree.assert_no_leaked_pointer_state();
4206 }
4207
4208 #[test]
4211 fn jump_on_press_leaves_a_touch_press_alone() {
4212 let (mut tree, s, value, _seen) = press_scene(true);
4213 let at = along(&tree, s, 0.75);
4214 let finger = tree.new_contact();
4215 tree.touch_down(finger, at);
4216 assert_eq!(value.get(), 0.0, "the contact landing wrote nothing");
4217 tree.touch_up(finger, at);
4218 assert!(
4219 (value.get() - 75.0).abs() < 1.01,
4220 "the tap did: {}",
4221 value.get()
4222 );
4223 tree.assert_no_leaked_pointer_state();
4224 }
4225
4226 #[test]
4227 fn without_jump_on_press_a_mouse_press_writes_nothing() {
4228 let (mut tree, s, value, seen) = press_scene(false);
4229 let at = along(&tree, s, 0.75);
4230 tree.pointer_move(at);
4231 tree.pointer_down_button(at, PointerButton::Primary);
4232 assert_eq!(value.get(), 0.0, "the press wrote nothing");
4233 tree.pointer_up_button(at, PointerButton::Primary);
4234 assert_eq!(seen.borrow().len(), 1, "the click wrote once, on release");
4235 }
4236
4237 #[test]
4240 fn a_jumping_double_click_reports_the_jump_and_then_the_reset() {
4241 let value = Signal::new(0.0_f32);
4242 let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
4243 let sink = seen.clone();
4244 let (mut tree, s) = horizontal_scene(
4245 Slider::new(value.clone(), 0.0, 100.0)
4246 .jump_on_press(true)
4247 .default_value(10.0)
4248 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
4249 );
4250 let at = along(&tree, s, 0.75);
4251 click(&mut tree, at);
4252 tree.advance_time(std::time::Duration::from_millis(100));
4253 tree.pointer_move(Point::new(at.x - 3.0, at.y));
4254 tree.pointer_down_button(Point::new(at.x - 3.0, at.y), PointerButton::Primary);
4255 assert_eq!(seen.borrow().len(), 1, "the second press did not jump");
4256 tree.pointer_up_button(Point::new(at.x - 3.0, at.y), PointerButton::Primary);
4257 let reported = seen.borrow().clone();
4258 assert_eq!(reported.len(), 2, "the jump, then the reset: {reported:?}");
4259 assert_eq!(value.get(), 10.0);
4260 }
4261
4262 #[test]
4263 fn a_below_readout_hangs_under_the_thumb() {
4264 let (mut tree, s) = horizontal_scene(
4265 Slider::new(Signal::new(-3.0_f32), -12.0, 12.0)
4266 .value_tooltip(db)
4267 .value_tooltip_placement(SliderValueTooltipPlacement::Below),
4268 );
4269 let bounds = tree.bounds(s);
4270 tree.pointer_move(bounds.center());
4271 tree.layout(window());
4272 let (_, rect) = readout(&tree).expect("up");
4273 let thumb = knob(&tree, s);
4274 assert!(
4275 rect.y >= bounds.bottom(),
4276 "below the slider: {rect:?} under {bounds:?}"
4277 );
4278 assert!(
4279 (rect.center().x - thumb.center().x).abs() < 0.51,
4280 "centred on the thumb: {rect:?} under {thumb:?}",
4281 );
4282 }
4283
4284 #[test]
4285 fn an_above_readout_on_a_vertical_slider_sits_over_the_thumb() {
4286 let mut tree = readout_tree();
4287 let s = tree.add(
4288 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
4289 .orientation(Orientation::Vertical)
4290 .value_tooltip(db)
4291 .value_tooltip_placement(SliderValueTooltipPlacement::Above),
4292 );
4293 let _row = tree.add(
4294 HStack::new()
4295 .child(FixedSize::new().width(150.0))
4296 .child(FixedSize::new().height(200.0).child(s)),
4297 );
4298 tree.layout(window());
4299 tree.pointer_move(knob(&tree, s).center());
4300 tree.layout(window());
4301 let (_, rect) = readout(&tree).expect("up");
4302 let (bounds, thumb) = (tree.bounds(s), knob(&tree, s));
4303 assert!(
4304 rect.bottom() <= thumb.y,
4305 "above the thumb: {rect:?} over {thumb:?}"
4306 );
4307 assert!(
4308 (rect.center().x - bounds.center().x).abs() < 0.51,
4309 "centred on the slider: {rect:?} over {bounds:?}",
4310 );
4311 }
4312
4313 #[test]
4316 fn a_follow_pointer_readout_rides_the_pointer_through_a_drag() {
4317 let (mut tree, s) = horizontal_scene(
4318 Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
4319 .step(6.0)
4320 .value_tooltip(db)
4321 .value_tooltip_placement(SliderValueTooltipPlacement::FollowPointer),
4322 );
4323 let bounds = tree.bounds(s);
4324 let start = knob(&tree, s).center();
4325 tree.pointer_move(start);
4326 tree.pointer_down_button(start, PointerButton::Primary);
4327 let mut pointer = start;
4328 for dx in [10.0, 37.0, 52.0] {
4329 pointer = Point::new(start.x + dx, start.y);
4330 tree.pointer_move(pointer);
4331 tree.layout(window());
4332 let (_, rect) = readout(&tree).expect("up for the whole drag");
4333 assert!(
4334 (rect.center().x - pointer.x).abs() < 0.51,
4335 "centred on the pointer: {rect:?} at {pointer:?}",
4336 );
4337 assert!(rect.bottom() <= bounds.y, "above the slider: {rect:?}");
4338 }
4339 let thumb = knob(&tree, s);
4340 assert!(
4341 (thumb.center().x - pointer.x).abs() > 2.0,
4342 "fixture: the snapped thumb is not under the pointer"
4343 );
4344
4345 tree.pointer_up_button(pointer, PointerButton::Primary);
4346 tree.layout(window());
4347 let (_, rect) = readout(&tree).expect("still hovered");
4348 assert!(
4349 (rect.center().x - thumb.center().x).abs() < 0.51,
4350 "back on the thumb once the drag is over: {rect:?} over {thumb:?}",
4351 );
4352 }
4353
4354 #[test]
4355 fn the_readout_takes_the_style_it_is_given() {
4356 use teksilo_core::build_context::BuildContext;
4357 use teksilo_core::styles::TooltipStyleConfig;
4358
4359 struct Counting(Rc<Cell<u32>>);
4360 impl TooltipStyle for Counting {
4361 fn make_body(&self, cfg: &TooltipStyleConfig, ctx: &mut BuildContext) -> WidgetId {
4362 self.0.set(self.0.get() + 1);
4363 crate::styles::RecipeTooltipStyle::default().make_body(cfg, ctx)
4364 }
4365 }
4366
4367 let used = Rc::new(Cell::new(0_u32));
4368 let (mut tree, s) = horizontal_scene(
4369 Slider::new(Signal::new(-3.0_f32), -12.0, 12.0)
4370 .value_tooltip(db)
4371 .value_tooltip_style(Counting(used.clone())),
4372 );
4373 tree.pointer_move(tree.bounds(s).center());
4374 tree.layout(window());
4375 let (text, _) = readout(&tree).expect("up");
4376 assert_eq!(text, "-3.0 dB");
4377 assert!(used.get() > 0, "the readout was built by the given style");
4378 }
4379}