1use std::rc::Rc;
39
40use teksilo_canvas::{Rect, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::binding::BindingLevel;
43use teksilo_core::build_context::BuildContext;
44use teksilo_core::event::{EventResponse, Key, WidgetEvent};
45use teksilo_core::signal::{Prop, Signal};
46use teksilo_core::styles::{SharedToggleStyle, ToggleStyle, ToggleStyleConfig};
47use teksilo_core::widget::{
48 CursorIcon, EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement,
49};
50use teksilo_core::widget_builder::HandlerSet;
51use teksilo_core::widget_id::WidgetId;
52
53pub use teksilo_core::styles::ToggleVariant;
57use teksilo_i18n::LocalizedString;
58
59pub struct Toggle {
61 on: Signal<bool>,
62 on_change: Option<Rc<dyn Fn(bool, &mut EventContext)>>,
63 label: Option<LocalizedString>,
64 enabled: Prop<bool>,
67 variant: ToggleVariant,
68 style: Option<SharedToggleStyle>,
69 hovered: Signal<bool>,
70 focused: Signal<bool>,
71 pressed: Signal<bool>,
72 body_id: Option<WidgetId>,
73 tooltip_text: Option<LocalizedString>,
77 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
79 composite_tooltip_content: Option<Box<dyn Widget>>,
81 labelled_externally: bool,
84}
85
86impl Toggle {
87 pub fn new(on: Signal<bool>) -> Self {
93 Self {
94 on,
95 on_change: None,
96 label: None,
97 enabled: Prop::Static(true),
98 variant: ToggleVariant::default(),
99 style: None,
100 hovered: Signal::new(false),
101 focused: Signal::new(false),
102 pressed: Signal::new(false),
103 body_id: None,
104 tooltip_text: None,
105 rich_tooltip_source: None,
106 composite_tooltip_content: None,
107 labelled_externally: false,
108 }
109 }
110
111 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
113 let ls: LocalizedString = label.into();
114 self.label = Some(ls);
115 self
116 }
117
118 pub fn on_change(mut self, f: impl Fn(bool, &mut EventContext) + 'static) -> Self {
131 self.on_change = Some(Rc::new(f));
132 self
133 }
134
135 pub fn labelled_externally(mut self) -> Self {
146 self.labelled_externally = true;
147 self
148 }
149
150 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
154 self.enabled = enabled.into();
155 self
156 }
157
158 pub fn variant(mut self, variant: ToggleVariant) -> Self {
164 self.variant = variant;
165 self
166 }
167
168 pub fn style(mut self, style: impl ToggleStyle) -> Self {
172 self.style = Some(Rc::new(style));
173 self
174 }
175
176 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
183 self.tooltip_text = Some(text.into());
184 self.rich_tooltip_source = None;
185 self.composite_tooltip_content = None;
186 self
187 }
188
189 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
194 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
195 self.tooltip_text = None;
196 self.composite_tooltip_content = None;
197 self
198 }
199
200 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
206 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
207 self.tooltip_text = None;
208 self.composite_tooltip_content = None;
209 self
210 }
211
212 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
217 self.composite_tooltip_content = Some(Box::new(content));
218 self.tooltip_text = None;
219 self.rich_tooltip_source = None;
220 self
221 }
222}
223
224impl std::fmt::Debug for Toggle {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 f.debug_struct("Toggle")
227 .field("enabled", &self.enabled.get())
228 .field("variant", &self.variant)
229 .finish()
230 }
231}
232
233impl Widget for Toggle {
234 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
235 let self_id = ctx.self_id();
236 ctx.enabled_when(self_id, self.enabled.clone());
238 let effective_enabled = ctx.effective_enabled_signal(self_id);
239 self.on.bind_to(
242 self_id,
243 ctx.binding_registry(),
244 BindingLevel::AccessibilityOnly,
245 );
246 let style: SharedToggleStyle = self
249 .style
250 .clone()
251 .or_else(|| ctx.theme().style_slots.toggle.clone())
252 .unwrap_or_else(|| {
253 Rc::new(crate::styles::RecipeToggleStyle::for_tokens(
254 &ctx.theme().input,
255 ))
256 });
257
258 let cfg = ToggleStyleConfig {
261 is_on: self.on.clone(),
262 is_hovered: self.hovered.clone(),
263 is_pressed: self.pressed.clone(),
264 is_focused: self.focused.clone(),
265 is_focus_visible: ctx.focus_visible(),
268 is_disabled: effective_enabled.map(|on| !*on),
271 variant: self.variant,
272 };
273 let body_id = style.make_body(&cfg, ctx);
274
275 let root = if let Some(ref label) = self.label {
280 use crate::primitives::{HStack, TextWidget};
281 use teksilo_tokens::TextStyleRole;
282 let label_widget = TextWidget::new(label.clone())
286 .style(TextStyleRole::Body)
287 .a11y_hidden();
288 let label_id = ctx.add(label_widget);
289 ctx.add(HStack::new().spacing(6.0).child(body_id).child(label_id))
290 } else {
291 body_id
292 };
293 self.body_id = Some(root);
294
295 if let Some(content) = self.composite_tooltip_content.take() {
297 let delay = ctx.theme().motion.tooltip_delay_heavy;
298 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
299 } else if let Some(source) = self.rich_tooltip_source.clone() {
300 let delay = ctx.theme().motion.tooltip_delay;
301 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
302 } else if let Some(text) = self.tooltip_text.clone() {
303 let delay = ctx.theme().motion.tooltip_delay;
304 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
305 }
306
307 let on = self.on.clone();
310 let hovered = self.hovered.clone();
311 let focused = self.focused.clone();
312 let pressed = self.pressed.clone();
313
314 let toggle = {
315 let on = on.clone();
316 let on_change = self.on_change.clone();
317 move |ctx: &mut EventContext| {
318 let now = !on.get();
319 on.set(now);
320 if let Some(ref f) = on_change {
321 f(now, ctx);
322 }
323 }
324 };
325
326 let mut handlers = HandlerSet::new()
330 .focusable(true)
331 .cursor(CursorIcon::Pointer);
332
333 {
334 let toggle = toggle.clone();
335 handlers = handlers.on_tap(move |_pos, ctx| {
336 toggle(ctx);
337 });
338 }
339 {
340 let hovered = hovered.clone();
341 handlers = handlers.on_hover(move |entered, _ctx| {
342 hovered.set(entered);
343 });
344 }
345 crate::common::interaction::bind_pressed(ctx, pressed.clone());
352 {
353 let toggle = toggle.clone();
354 let key_pressed = std::cell::Cell::new(false);
358 handlers = handlers.on_key(move |event, ctx| match event {
359 WidgetEvent::KeyDown {
360 key: Key::Space, ..
361 } => {
362 key_pressed.set(true);
363 EventResponse::Handled
364 }
365 WidgetEvent::KeyUp {
366 key: Key::Space, ..
367 } => {
368 if !key_pressed.replace(false) {
369 return EventResponse::Ignored;
370 }
371 toggle(ctx);
372 EventResponse::Handled
373 }
374 _ => EventResponse::Ignored,
375 });
376 }
377 {
378 let focused = focused.clone();
379 handlers = handlers.on_focus(move |gained, _ctx| focused.set(gained));
380 }
381 {
382 let toggle = toggle.clone();
383 handlers = handlers.on_access_action(move |action, ctx| {
384 if action == teksilo_core::accesskit::Action::Click {
385 toggle(ctx);
386 EventResponse::Handled
387 } else {
388 EventResponse::Ignored
389 }
390 });
391 }
392
393 ctx.apply_self_handlers(handlers);
394
395 vec![root]
402 }
403
404 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
405 self.body_id
406 .and_then(|id| ctx.child_size(id, proposal))
407 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
408 .into()
409 }
410
411 fn place_children(
412 &self,
413 bounds: Rect,
414 _proposal: SizeProposal,
415 children: &mut [WidgetPlacement],
416 _ctx: &LayoutContext,
417 ) {
418 if let Some(child) = children.first_mut() {
419 child.origin = bounds.origin();
420 child.size = bounds.size();
421 }
422 }
423
424 fn children(&self) -> Vec<WidgetId> {
425 self.body_id.into_iter().collect()
426 }
427
428 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
429 debug_assert!(
430 self.label.is_some() || self.labelled_externally,
431 "Toggle is missing an accessible label — \
432 screen readers will announce \"switch\" with no context. \
433 Call .label(...) when constructing the widget."
434 );
435 builder.set_role(teksilo_core::accesskit::Role::Switch);
436 if let Some(ref label) = self.label {
437 builder.set_name(label.resolve_now());
438 }
439 builder.set_toggled(self.on.get());
440 builder.add_action(teksilo_core::accesskit::Action::Click);
442 builder.add_action(teksilo_core::accesskit::Action::Focus);
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use std::time::Duration;
449
450 use super::*;
451
452 use teksilo_core::event::Modifiers;
453 use teksilo_core::widget_tree::WidgetTree;
454 use teksilo_i18n::lit;
455
456 #[test]
457 fn focus_ring_only_under_focus_visible() {
458 let theme = teksilo_core::presets::intui::light();
461 let ring = theme.colors.focus_ring.to_array();
462 let mut tree = WidgetTree::new().with_theme(theme);
463 let t = tree.add(Toggle::new(Signal::new(false)));
464 tree.layout(SizeProposal::exact(120.0, 60.0));
465
466 tree.focus(t);
467 assert!(
468 !frame_has_ring(&tree.render(), ring),
469 "no focus ring while focus-visible is false (pointer modality)",
470 );
471
472 tree.press_key(Key::ArrowDown, Modifiers::NONE);
473 assert!(
474 frame_has_ring(&tree.render(), ring),
475 "focus ring shows under keyboard modality",
476 );
477 }
478
479 #[test]
480 fn is_pressed_tracks_pointer_down_and_up() {
481 use std::cell::RefCell;
482 use std::rc::Rc;
483 use teksilo_core::event::PointerButton;
484 use teksilo_core::styles::{ToggleStyle, ToggleStyleConfig};
485
486 struct CaptureStyle(Rc<RefCell<Option<Signal<bool>>>>);
489 impl ToggleStyle for CaptureStyle {
490 fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
491 *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
492 ctx.add(crate::primitives::RectWidget::new())
493 }
494 }
495
496 let captured: Rc<RefCell<Option<Signal<bool>>>> = Rc::new(RefCell::new(None));
497 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
498 let t = tree.add(Toggle::new(Signal::new(false)).style(CaptureStyle(captured.clone())));
499 tree.layout(SizeProposal::exact(120.0, 60.0));
500
501 let pressed = captured.borrow().clone().expect("is_pressed captured");
502 assert!(!pressed.get(), "not pressed initially");
503
504 let b = tree.bounds(t);
505 let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
506 tree.pointer_down_button(center, PointerButton::Primary);
507 assert!(pressed.get(), "pressed after PointerDown");
508 tree.pointer_up_button(center, PointerButton::Primary);
509 assert!(!pressed.get(), "released after PointerUp");
510 }
511
512 fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
517 frame
518 .shapes
519 .iter()
520 .any(|s| s.color == color && s.stroke_width > 0.0)
521 || frame.cosmetic_lines.iter().any(|l| l.color == color)
522 }
523
524 #[test]
529 fn a_middle_press_leaves_the_toggle_alone() {
530 use teksilo_core::event::PointerButton;
531
532 let on = Signal::new(false);
533 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
534 let t = tree.add(Toggle::new(on.clone()));
535 tree.layout(SizeProposal::exact(120.0, 60.0));
536 let b = tree.bounds(t);
537 let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
538
539 tree.pointer_down_button(center, PointerButton::Middle);
540 assert!(!tree.is_pressed(t), "a middle press raises no visual");
541 tree.pointer_up_button(center, PointerButton::Middle);
542 assert!(!on.get(), "and flips nothing");
543
544 tree.pointer_down_button(center, PointerButton::Primary);
546 assert!(tree.is_pressed(t), "a primary press raises the visual");
547 tree.pointer_up_button(center, PointerButton::Primary);
548 assert!(!tree.is_pressed(t), "the release clears it");
549 assert!(on.get(), "and the toggle flipped");
550 }
551
552 #[test]
553 fn click_toggles_state() {
554 let on = Signal::new(false);
555 let mut tree = WidgetTree::new();
556 let t = tree.add(Toggle::new(on.clone()));
557 tree.layout(SizeProposal::exact(100.0, 60.0));
558
559 tree.click(t);
560 assert!(on.get());
561 tree.click(t);
562 assert!(!on.get());
563 }
564
565 #[test]
566 fn space_toggles_state() {
567 let on = Signal::new(false);
568 let mut tree = WidgetTree::new();
569 let t = tree.add(Toggle::new(on.clone()));
570 tree.layout(SizeProposal::exact(100.0, 60.0));
571
572 tree.focus(t);
573 tree.press_key(Key::Space, Modifiers::NONE);
574 assert!(on.get());
575 }
576
577 #[test]
578 fn lone_keyup_does_not_toggle() {
579 let on = Signal::new(false);
581 let mut tree = WidgetTree::new();
582 let t = tree.add(Toggle::new(on.clone()));
583 tree.layout(SizeProposal::exact(100.0, 60.0));
584
585 tree.focus(t);
586 tree.dispatch_event(WidgetEvent::KeyUp {
587 key: Key::Space,
588 modifiers: Modifiers::NONE,
589 });
590 assert!(!on.get(), "a lone KeyUp must not toggle the switch");
591
592 tree.press_key(Key::Space, Modifiers::NONE);
593 assert!(on.get());
594 }
595
596 #[test]
597 fn animation_runs_after_toggle() {
598 let on = Signal::new(false);
599 let mut tree = WidgetTree::new();
600 let t = tree.add(Toggle::new(on.clone()));
601 tree.layout(SizeProposal::exact(100.0, 60.0));
602
603 tree.click(t); assert!(on.get());
605
606 tree.tick_animations(Duration::from_millis(75));
608 assert!(tree.has_active_animations());
609
610 tree.tick_animations(Duration::from_millis(200));
612 assert!(!tree.has_active_animations());
613 }
614
615 #[test]
616 fn accessibility() {
617 let on = Signal::new(true);
618 let mut tree = WidgetTree::new();
619 let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
620 tree.layout(SizeProposal::exact(100.0, 60.0));
621 let info = tree.accessibility_node(t);
622 assert_eq!(info.role(), teksilo_core::accesskit::Role::Switch);
623 assert!(info.is_toggled());
624 }
625
626 #[test]
627 fn labeled_toggle_does_not_duplicate_its_label_as_a_separate_at_node() {
628 use teksilo_core::accessibility::widget_id_to_node_id;
632 let on = Signal::new(false);
633 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
634 let id = tree.add(Toggle::new(on).label(lit!("Dark mode")));
635 tree.layout(SizeProposal::exact(200.0, 60.0));
636 let update = tree.sync_accessibility();
637
638 let toggle_node_id = widget_id_to_node_id(id);
639 let toggle_node = update
640 .nodes
641 .iter()
642 .find(|(nid, _)| *nid == toggle_node_id)
643 .map(|(_, node)| node)
644 .expect("toggle node");
645 assert_eq!(
646 toggle_node.label(),
647 Some("Dark mode"),
648 "hiding the embedded label must not take the switch's own name away with it",
649 );
650
651 assert!(
652 !update.nodes.iter().any(|(_, node)| node.role()
653 == teksilo_core::accesskit::Role::Label
654 && node.label() == Some("Dark mode")),
655 "the label Text widget must not survive as a duplicate-named AT node",
656 );
657 }
658
659 #[test]
667 fn labeled_toggle_does_not_orphan_hstack_wrapper() {
668 let on = Signal::new(false);
669 let mut tree = WidgetTree::new();
670 let _t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
671 tree.layout(SizeProposal::exact(200.0, 60.0));
672 let update = tree.sync_accessibility();
673 let mut seen = std::collections::HashMap::new();
674 for (parent_id, node) in &update.nodes {
675 for &child_id in node.children() {
676 let prev = seen.insert(child_id, *parent_id);
677 assert!(
678 prev.is_none(),
679 "duplicate AT child {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
680 );
681 }
682 }
683 }
684
685 #[test]
686 fn a_reader_is_told_each_change_as_it_happens() {
687 use crate::common::heard_test::{Heard, Listener};
691 use teksilo_core::accesskit::Toggled;
692
693 let on = Signal::new(false);
694 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
695 let t = tree.add(Toggle::new(on.clone()).label(lit!("Dark mode")));
696 let p = SizeProposal::exact(200.0, 60.0);
697 tree.layout(p);
698 tree.focus(t);
699 tree.layout(p);
700 let mut listener = Listener::attach(&mut tree);
701
702 tree.press_key(Key::Space, Modifiers::NONE);
703 tree.layout(p);
704 assert_eq!(
705 listener.heard(&mut tree),
706 vec![Heard::FocusToggled(Toggled::True)],
707 "Space"
708 );
709
710 tree.dispatch_access_action(
711 teksilo_core::accessibility::widget_id_to_node_id(t),
712 teksilo_core::accesskit::Action::Click,
713 None,
714 &mut teksilo_core::NoopWindowOps,
715 );
716 tree.layout(p);
717 assert_eq!(
718 listener.heard(&mut tree),
719 vec![Heard::FocusToggled(Toggled::False)],
720 "a screen reader's click"
721 );
722 }
723
724 #[test]
725 fn on_change_fires_for_every_user_path_and_reports_the_new_value() {
726 use std::cell::RefCell;
727 use std::rc::Rc;
728
729 let on = Signal::new(false);
730 let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
731 let sink = seen.clone();
732 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
733 let t = tree.add(
734 Toggle::new(on.clone())
735 .label(lit!("Dark mode"))
736 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
737 );
738 tree.layout(SizeProposal::exact(200.0, 60.0));
739
740 tree.click(t);
741 tree.focus(t);
742 tree.press_key(Key::Space, Modifiers::NONE);
743 tree.dispatch_access_action(
744 teksilo_core::accessibility::widget_id_to_node_id(t),
745 teksilo_core::accesskit::Action::Click,
746 None,
747 &mut teksilo_core::NoopWindowOps,
748 );
749
750 assert_eq!(*seen.borrow(), vec![true, false, true]);
751 assert!(on.get());
752 }
753
754 #[test]
755 fn on_change_is_silent_for_a_programmatic_write() {
756 use std::cell::Cell;
757 use std::rc::Rc;
758
759 let on = Signal::new(false);
760 let fired = Rc::new(Cell::new(false));
761 let sink = fired.clone();
762 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
763 let _t = tree.add(
764 Toggle::new(on.clone())
765 .label(lit!("Dark mode"))
766 .on_change(move |_now, _ctx| sink.set(true)),
767 );
768 tree.layout(SizeProposal::exact(200.0, 60.0));
769
770 on.set(true);
772 assert!(!fired.get());
773 }
774
775 #[test]
776 fn accessibility_has_actions() {
777 let on = Signal::new(false);
778 let mut tree = WidgetTree::new();
779 let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
780 tree.layout(SizeProposal::exact(100.0, 60.0));
781 let info = tree.accessibility_node(t);
782 assert!(
783 info.actions()
784 .contains(&teksilo_core::accesskit::Action::Click)
785 );
786 }
787
788 #[test]
796 fn a_rich_tooltip_appears_on_hover_too() {
797 let mut tree = WidgetTree::new();
798 let id = tree.add(
799 Toggle::new(Signal::new(false))
800 .label(lit!("Wi-Fi"))
801 .rich_tooltip_content(crate::tooltip::TooltipContent::new(
802 "toggle.rich",
803 lit!("What this switch does"),
804 )),
805 );
806 tree.layout(SizeProposal::exact(300.0, 200.0));
807 tree.pointer_move(tree.bounds(id).center());
808 tree.advance_time(std::time::Duration::from_secs(1));
809 assert_eq!(
810 tree.active_overlays().len(),
811 1,
812 "a rich tooltip on a Toggle must open under the pointer"
813 );
814 }
815
816 #[test]
817 fn tooltip_appears_on_hover() {
818 let mut tree = WidgetTree::new();
819 let id = tree.add(
820 Toggle::new(Signal::new(false))
821 .label(lit!("Wi-Fi"))
822 .tooltip(lit!("Tip")),
823 );
824 tree.layout(SizeProposal::exact(300.0, 200.0));
825 tree.pointer_move(tree.bounds(id).center());
826 tree.advance_time(std::time::Duration::from_secs(1));
827 assert_eq!(
828 tree.active_overlays().len(),
829 1,
830 "tooltip should appear on hover"
831 );
832 assert!(tree.find_by_label("Tip").is_some());
833 }
834
835 struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<Signal<bool>>>>);
840
841 impl teksilo_core::styles::ToggleStyle for PressProbe {
842 fn make_body(
843 &self,
844 cfg: &teksilo_core::styles::ToggleStyleConfig,
845 ctx: &mut BuildContext,
846 ) -> WidgetId {
847 *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
848 ctx.add(crate::primitives::FixedSize::new().width(36.0).height(20.0))
849 }
850 }
851
852 fn probed_toggle() -> (WidgetTree, WidgetId, Signal<bool>, Signal<bool>) {
853 let probe: std::rc::Rc<std::cell::RefCell<Option<Signal<bool>>>> =
854 std::rc::Rc::new(std::cell::RefCell::new(None));
855 let on = Signal::new(false);
856 let mut theme = teksilo_core::presets::intui::light();
857 theme.style_slots.toggle = Some(std::rc::Rc::new(PressProbe(probe.clone())));
858 let mut tree = WidgetTree::new().with_theme(theme);
859 let t = tree.add(Toggle::new(on.clone()));
860 tree.layout(SizeProposal::exact(200.0, 60.0));
861 let pressed = probe.borrow().clone().expect("style ran");
862 (tree, t, pressed, on)
863 }
864
865 #[test]
870 fn a_touch_tap_flips_on_release_and_a_slide_off_abandons_it() {
871 use crate::button::press_test_support::{finger, touch};
872 use teksilo_core::pointer::PointerPhase;
873
874 let (mut tree, t, pressed, on) = probed_toggle();
875 let bounds = tree.bounds(t);
876 let at = bounds.center();
877 let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 90.0);
878
879 let id = finger();
880 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
881 assert!(pressed.get(), "the contact holds the switch");
882 assert!(!on.get(), "and has flipped nothing");
883 tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
884 assert!(!pressed.get(), "the press slid off its target");
885 tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
886 assert!(!on.get(), "a release off the switch flips nothing");
887
888 let id = finger();
889 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 100));
890 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 130));
891 assert!(on.get(), "and a tap that stays on it does");
892 assert!(!pressed.get());
893 }
894
895 #[test]
897 fn a_mouse_click_presses_then_flips_on_release() {
898 let (mut tree, t, pressed, on) = probed_toggle();
899 let at = tree.bounds(t).center();
900 tree.pointer_move(at);
901 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
902 assert!(pressed.get());
903 assert!(!on.get());
904 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
905 assert!(!pressed.get());
906 assert!(on.get());
907 }
908}