1use std::rc::Rc;
32
33use teksilo_canvas::{Rect, SizeProposal};
34use teksilo_core::accessibility::AccessNodeBuilder;
35use teksilo_core::build_context::BuildContext;
36use teksilo_core::event::{EventResponse, Key, WidgetEvent};
37use teksilo_core::focus::FocusOrigin;
38use teksilo_core::signal::{Prop, Signal};
39use teksilo_core::styles::{SharedToggleStyle, ToggleStyle, ToggleStyleConfig};
40use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
41use teksilo_core::widget_builder::HandlerSet;
42use teksilo_core::widget_id::WidgetId;
43
44pub use teksilo_core::styles::ToggleVariant;
48use teksilo_i18n::LocalizedString;
49
50pub struct Toggle {
52 on: Signal<bool>,
53 label: Option<LocalizedString>,
54 enabled: Prop<bool>,
57 variant: ToggleVariant,
58 style: Option<SharedToggleStyle>,
59 hovered: Signal<bool>,
60 focused: Signal<bool>,
61 pressed: Signal<bool>,
62 focus_origin: Signal<Option<FocusOrigin>>,
63 body_id: Option<WidgetId>,
64 tooltip_text: Option<LocalizedString>,
68 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
70 composite_tooltip_content: Option<Box<dyn Widget>>,
72 labelled_externally: bool,
75}
76
77impl Toggle {
78 pub fn new(on: Signal<bool>) -> Self {
81 Self {
82 on,
83 label: None,
84 enabled: Prop::Static(true),
85 variant: ToggleVariant::default(),
86 style: None,
87 hovered: Signal::new(false),
88 focused: Signal::new(false),
89 pressed: Signal::new(false),
90 focus_origin: Signal::new(None),
91 body_id: None,
92 tooltip_text: None,
93 rich_tooltip_source: None,
94 composite_tooltip_content: None,
95 labelled_externally: false,
96 }
97 }
98
99 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
101 let ls: LocalizedString = label.into();
102 self.label = Some(ls);
103 self
104 }
105
106 pub fn labelled_externally(mut self) -> Self {
116 self.labelled_externally = true;
117 self
118 }
119
120 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
124 self.enabled = enabled.into();
125 self
126 }
127
128 pub fn variant(mut self, variant: ToggleVariant) -> Self {
134 self.variant = variant;
135 self
136 }
137
138 pub fn style(mut self, style: impl ToggleStyle) -> Self {
142 self.style = Some(Rc::new(style));
143 self
144 }
145
146 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
153 self.tooltip_text = Some(text.into());
154 self.rich_tooltip_source = None;
155 self.composite_tooltip_content = None;
156 self
157 }
158
159 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
164 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
165 self.tooltip_text = None;
166 self.composite_tooltip_content = None;
167 self
168 }
169
170 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
176 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
177 self.tooltip_text = None;
178 self.composite_tooltip_content = None;
179 self
180 }
181
182 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
187 self.composite_tooltip_content = Some(Box::new(content));
188 self.tooltip_text = None;
189 self.rich_tooltip_source = None;
190 self
191 }
192}
193
194impl std::fmt::Debug for Toggle {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 f.debug_struct("Toggle")
197 .field("enabled", &self.enabled.get())
198 .field("variant", &self.variant)
199 .finish()
200 }
201}
202
203impl Widget for Toggle {
204 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
205 let self_id = ctx.self_id();
206 ctx.enabled_when(self_id, self.enabled.clone());
208 let effective_enabled = ctx.effective_enabled_signal(self_id);
209 let style: SharedToggleStyle = self
212 .style
213 .clone()
214 .or_else(|| ctx.theme().style_slots.toggle.clone())
215 .unwrap_or_else(|| Rc::new(crate::styles::RecipeToggleStyle::default()));
216
217 let cfg = ToggleStyleConfig {
220 is_on: self.on.clone(),
221 is_hovered: self.hovered.clone(),
222 is_pressed: self.pressed.clone(),
223 is_focused: self.focused.clone(),
224 is_focus_visible: ctx.focus_visible(),
227 is_disabled: effective_enabled.map(|on| !*on),
230 variant: self.variant,
231 };
232 let body_id = style.make_body(&cfg, ctx);
233
234 let root = if let Some(ref label) = self.label {
239 use crate::primitives::{HStack, TextWidget};
240 use teksilo_tokens::TextStyleRole;
241 let label_widget = TextWidget::new(label.clone()).style(TextStyleRole::Body);
242 let label_id = ctx.add(label_widget);
243 ctx.add(
244 HStack::new()
245 .spacing(6.0)
246 .add_child(body_id)
247 .add_child(label_id),
248 )
249 } else {
250 body_id
251 };
252 self.body_id = Some(root);
253
254 if let Some(content) = self.composite_tooltip_content.take() {
256 let delay = ctx.theme().motion.tooltip_delay_heavy;
257 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
258 } else if let Some(source) = self.rich_tooltip_source.clone() {
259 let delay = ctx.theme().motion.tooltip_delay;
260 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
261 } else if let Some(text) = self.tooltip_text.clone() {
262 let delay = ctx.theme().motion.tooltip_delay;
263 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
264 }
265
266 let on = self.on.clone();
269 let hovered = self.hovered.clone();
270 let focused = self.focused.clone();
271 let pressed = self.pressed.clone();
272 let focus_origin = self.focus_origin.clone();
273
274 let toggle = {
275 let on = on.clone();
276 move || {
277 on.set(!on.get());
278 }
279 };
280
281 let mut handlers = HandlerSet::new()
285 .focusable(true)
286 .cursor(CursorIcon::Pointer);
287
288 {
289 let toggle = toggle.clone();
290 handlers = handlers.on_tap(move |_pos, _ctx| {
291 toggle();
292 });
293 }
294 {
295 let hovered = hovered.clone();
296 handlers = handlers.on_hover(move |entered, _ctx| {
297 hovered.set(entered);
298 });
299 }
300 {
301 let pressed = pressed.clone();
306 handlers = handlers.on_pointer_event(move |event, _ctx| {
307 use teksilo_core::event::{PointerButton, WidgetEvent};
308 match event {
309 WidgetEvent::PointerDown {
310 button: PointerButton::Primary,
311 ..
312 } => pressed.set(true),
313 WidgetEvent::PointerUp { .. } | WidgetEvent::PointerLeave => pressed.set(false),
314 _ => {}
315 }
316 teksilo_core::event::EventResponse::Ignored
317 });
318 }
319 {
320 let toggle = toggle.clone();
321 let key_pressed = std::cell::Cell::new(false);
325 handlers = handlers.on_key(move |event, _ctx| match event {
326 WidgetEvent::KeyDown {
327 key: Key::Space, ..
328 } => {
329 key_pressed.set(true);
330 EventResponse::Handled
331 }
332 WidgetEvent::KeyUp {
333 key: Key::Space, ..
334 } => {
335 if !key_pressed.replace(false) {
336 return EventResponse::Ignored;
337 }
338 toggle();
339 EventResponse::Handled
340 }
341 _ => EventResponse::Ignored,
342 });
343 }
344 {
345 let focused = focused.clone();
346 let focus_origin = focus_origin.clone();
347 let hovered_for_focus = hovered.clone();
348 handlers = handlers.on_focus(move |gained, _ctx| {
349 focused.set(gained);
350 if gained {
351 focus_origin.set(Some(if hovered_for_focus.get() {
352 FocusOrigin::Pointer
353 } else {
354 FocusOrigin::Keyboard
355 }));
356 } else {
357 focus_origin.set(None);
358 }
359 });
360 }
361 {
362 let toggle = toggle.clone();
363 handlers = handlers.on_access_action(move |action, _ctx| {
364 if action == teksilo_core::accesskit::Action::Click {
365 toggle();
366 EventResponse::Handled
367 } else {
368 EventResponse::Ignored
369 }
370 });
371 }
372
373 ctx.apply_self_handlers(handlers);
374
375 vec![root]
382 }
383
384 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
385 self.body_id
386 .and_then(|id| ctx.child_size(id, proposal))
387 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
388 .into()
389 }
390
391 fn place_children(
392 &self,
393 bounds: Rect,
394 _proposal: SizeProposal,
395 children: &mut [WidgetPlacement],
396 _ctx: &LayoutContext,
397 ) {
398 if let Some(child) = children.first_mut() {
399 child.origin = bounds.origin();
400 child.size = bounds.size();
401 }
402 }
403
404 fn children(&self) -> Vec<WidgetId> {
405 self.body_id.into_iter().collect()
406 }
407
408 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
409 debug_assert!(
410 self.label.is_some() || self.labelled_externally,
411 "Toggle is missing an accessible label — \
412 screen readers will announce \"switch\" with no context. \
413 Call .label(...) when constructing the widget."
414 );
415 builder.set_role(teksilo_core::accesskit::Role::Switch);
416 if let Some(ref label) = self.label {
417 builder.set_name(label.resolve_now());
418 }
419 builder.set_toggled(self.on.get());
420 builder.add_action(teksilo_core::accesskit::Action::Click);
422 builder.add_action(teksilo_core::accesskit::Action::Focus);
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use std::time::Duration;
429
430 use super::*;
431
432 use teksilo_core::event::Modifiers;
433 use teksilo_core::widget_tree::WidgetTree;
434 use teksilo_i18n::lit;
435
436 #[test]
437 fn focus_ring_only_under_focus_visible() {
438 let theme = teksilo_core::presets::intui::light();
441 let ring = theme.colors.focus_ring.to_array();
442 let mut tree = WidgetTree::new().with_theme(theme);
443 let t = tree.add(Toggle::new(Signal::new(false)));
444 tree.layout(SizeProposal::exact(120.0, 60.0));
445
446 tree.focus(t);
447 assert!(
448 !frame_has_ring(&tree.render(), ring),
449 "no focus ring while focus-visible is false (pointer modality)",
450 );
451
452 tree.press_key(Key::ArrowDown, Modifiers::NONE);
453 assert!(
454 frame_has_ring(&tree.render(), ring),
455 "focus ring shows under keyboard modality",
456 );
457 }
458
459 #[test]
460 fn is_pressed_tracks_pointer_down_and_up() {
461 use std::cell::RefCell;
462 use std::rc::Rc;
463 use teksilo_core::event::PointerButton;
464 use teksilo_core::styles::{ToggleStyle, ToggleStyleConfig};
465
466 struct CaptureStyle(Rc<RefCell<Option<Signal<bool>>>>);
469 impl ToggleStyle for CaptureStyle {
470 fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
471 *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
472 ctx.add(crate::primitives::RectWidget::new())
473 }
474 }
475
476 let captured: Rc<RefCell<Option<Signal<bool>>>> = Rc::new(RefCell::new(None));
477 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
478 let t = tree.add(Toggle::new(Signal::new(false)).style(CaptureStyle(captured.clone())));
479 tree.layout(SizeProposal::exact(120.0, 60.0));
480
481 let pressed = captured.borrow().clone().expect("is_pressed captured");
482 assert!(!pressed.get(), "not pressed initially");
483
484 let b = tree.bounds(t);
485 let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
486 tree.pointer_down_button(center, PointerButton::Primary);
487 assert!(pressed.get(), "pressed after PointerDown");
488 tree.pointer_up_button(center, PointerButton::Primary);
489 assert!(!pressed.get(), "released after PointerUp");
490 }
491
492 fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
497 frame
498 .shapes
499 .iter()
500 .any(|s| s.color == color && s.stroke_width > 0.0)
501 || frame.cosmetic_lines.iter().any(|l| l.color == color)
502 }
503
504 #[test]
505 fn click_toggles_state() {
506 let on = Signal::new(false);
507 let mut tree = WidgetTree::new();
508 let t = tree.add(Toggle::new(on.clone()));
509 tree.layout(SizeProposal::exact(100.0, 60.0));
510
511 tree.click(t);
512 assert!(on.get());
513 tree.click(t);
514 assert!(!on.get());
515 }
516
517 #[test]
518 fn space_toggles_state() {
519 let on = Signal::new(false);
520 let mut tree = WidgetTree::new();
521 let t = tree.add(Toggle::new(on.clone()));
522 tree.layout(SizeProposal::exact(100.0, 60.0));
523
524 tree.focus(t);
525 tree.press_key(Key::Space, Modifiers::NONE);
526 assert!(on.get());
527 }
528
529 #[test]
530 fn lone_keyup_does_not_toggle() {
531 let on = Signal::new(false);
533 let mut tree = WidgetTree::new();
534 let t = tree.add(Toggle::new(on.clone()));
535 tree.layout(SizeProposal::exact(100.0, 60.0));
536
537 tree.focus(t);
538 tree.dispatch_event(WidgetEvent::KeyUp {
539 key: Key::Space,
540 modifiers: Modifiers::NONE,
541 });
542 assert!(!on.get(), "a lone KeyUp must not toggle the switch");
543
544 tree.press_key(Key::Space, Modifiers::NONE);
545 assert!(on.get());
546 }
547
548 #[test]
549 fn animation_runs_after_toggle() {
550 let on = Signal::new(false);
551 let mut tree = WidgetTree::new();
552 let t = tree.add(Toggle::new(on.clone()));
553 tree.layout(SizeProposal::exact(100.0, 60.0));
554
555 tree.click(t); assert!(on.get());
557
558 tree.tick_animations(Duration::from_millis(75));
560 assert!(tree.has_active_animations());
561
562 tree.tick_animations(Duration::from_millis(200));
564 assert!(!tree.has_active_animations());
565 }
566
567 #[test]
568 fn accessibility() {
569 let on = Signal::new(true);
570 let mut tree = WidgetTree::new();
571 let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
572 tree.layout(SizeProposal::exact(100.0, 60.0));
573 let info = tree.accessibility_node(t);
574 assert_eq!(info.role(), teksilo_core::accesskit::Role::Switch);
575 assert!(info.is_toggled());
576 }
577
578 #[test]
586 fn labeled_toggle_does_not_orphan_hstack_wrapper() {
587 let on = Signal::new(false);
588 let mut tree = WidgetTree::new();
589 let _t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
590 tree.layout(SizeProposal::exact(200.0, 60.0));
591 let update = tree.sync_accessibility();
592 let mut seen = std::collections::HashMap::new();
593 for (parent_id, node) in &update.nodes {
594 for &child_id in node.children() {
595 let prev = seen.insert(child_id, *parent_id);
596 assert!(
597 prev.is_none(),
598 "duplicate AT child {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
599 );
600 }
601 }
602 }
603
604 #[test]
605 fn accessibility_has_actions() {
606 let on = Signal::new(false);
607 let mut tree = WidgetTree::new();
608 let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
609 tree.layout(SizeProposal::exact(100.0, 60.0));
610 let info = tree.accessibility_node(t);
611 assert!(
612 info.actions()
613 .contains(&teksilo_core::accesskit::Action::Click)
614 );
615 }
616
617 #[test]
625 fn a_rich_tooltip_appears_on_hover_too() {
626 let mut tree = WidgetTree::new();
627 let id = tree.add(
628 Toggle::new(Signal::new(false))
629 .label(lit!("Wi-Fi"))
630 .rich_tooltip_content(crate::tooltip::TooltipContent::new(
631 "toggle.rich",
632 lit!("What this switch does"),
633 )),
634 );
635 tree.layout(SizeProposal::exact(300.0, 200.0));
636 tree.pointer_move(tree.bounds(id).center());
637 tree.advance_time(std::time::Duration::from_secs(1));
638 assert_eq!(
639 tree.active_overlays().len(),
640 1,
641 "a rich tooltip on a Toggle must open under the pointer"
642 );
643 }
644
645 #[test]
646 fn tooltip_appears_on_hover() {
647 let mut tree = WidgetTree::new();
648 let id = tree.add(
649 Toggle::new(Signal::new(false))
650 .label(lit!("Wi-Fi"))
651 .tooltip(lit!("Tip")),
652 );
653 tree.layout(SizeProposal::exact(300.0, 200.0));
654 tree.pointer_move(tree.bounds(id).center());
655 tree.advance_time(std::time::Duration::from_secs(1));
656 assert_eq!(
657 tree.active_overlays().len(),
658 1,
659 "tooltip should appear on hover"
660 );
661 assert!(tree.find_by_label("Tip").is_some());
662 }
663}