Skip to main content

cranpose_ui/modifier/
toggleable.rs

1use super::{inspector_metadata, Modifier, SemanticsConfiguration};
2use std::rc::Rc;
3
4impl Modifier {
5    /// Make the component a two-state control.
6    ///
7    /// This is Compose's `Modifier.toggleable(value, onValueChange)`: a click
8    /// hands the callback the **new** value, so a caller writes
9    /// `.toggleable(checked, move |next| state.set(next))` and never has to
10    /// read the old one back out of its own state to invert it.
11    ///
12    /// Compose deliberately sets no role here — a toggleable row could be a
13    /// checkbox or a switch, and only the control inside it knows which — and
14    /// this does the same.
15    ///
16    /// The **state** it does publish: `toggled`, Compose's `toggleableState`,
17    /// so a screen reader landing on the row says whether it is on without the
18    /// caller spelling it into the description. `description` stays because a
19    /// row still needs a name, and a caller that wants the state spoken a
20    /// particular way ("Haptics, on") can still say it there.
21    pub fn toggleable(
22        self,
23        value: bool,
24        description: Option<String>,
25        on_value_change: impl Fn(bool) + 'static,
26    ) -> Self {
27        let on_value_change = Rc::new(on_value_change);
28        let toggled = value;
29        let modifier = Modifier::empty()
30            .clickable(move |_point| on_value_change(!toggled))
31            .with_inspector_metadata(inspector_metadata("toggleable", move |info| {
32                info.add_property("value", if toggled { "true" } else { "false" });
33                info.add_property("onValueChange", "provided");
34            }))
35            .then(
36                Modifier::empty().semantics(move |config: &mut SemanticsConfiguration| {
37                    config.is_clickable = true;
38                    config.toggled = Some(toggled);
39                    if let Some(description) = &description {
40                        config.content_description = Some(description.clone());
41                    }
42                }),
43            );
44        self.then(modifier)
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use crate::modifier::{collect_semantics_from_modifier, collect_slices_from_modifier, Point};
52    use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
53    use std::cell::Cell;
54
55    /// A click on a real chain is a pointer down followed by a pointer up: the
56    /// click fires on the release, and only then.
57    fn tap(modifier: &Modifier) {
58        let slices = collect_slices_from_modifier(modifier);
59        let handlers = slices.pointer_inputs();
60        assert_eq!(handlers.len(), 1, "toggleable takes pointer input once");
61        let at = Point { x: 4.0, y: 4.0 };
62        for kind in [PointerEventKind::Down, PointerEventKind::Up] {
63            let mut event = PointerEvent::new(kind, at, at);
64            event.buttons = PointerButtons::new().with(PointerButton::Primary);
65            handlers[0](event);
66        }
67    }
68
69    #[test]
70    fn a_click_reports_the_new_value_not_the_old_one() {
71        let _app_context = crate::render_state::app_context_test_scope();
72        for start in [false, true] {
73            let seen: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
74            let sink = seen.clone();
75            let modifier =
76                Modifier::empty().toggleable(start, None, move |next| sink.set(Some(next)));
77            tap(&modifier);
78            assert_eq!(
79                seen.get(),
80                Some(!start),
81                "a toggle hands over the value it is moving to"
82            );
83        }
84    }
85
86    #[test]
87    fn a_toggleable_row_reads_as_clickable_and_carries_its_description() {
88        let modifier = Modifier::empty().toggleable(true, Some("Haptics, on".to_string()), |_| {});
89        let semantics = collect_semantics_from_modifier(&modifier)
90            .expect("a toggleable row publishes semantics");
91        assert!(semantics.is_clickable);
92        assert_eq!(
93            semantics.content_description.as_deref(),
94            Some("Haptics, on")
95        );
96        // The state is published, not left for the caller to spell into the
97        // description: a reader landing here can say the row is on.
98        assert_eq!(semantics.toggled, Some(true));
99        // And no role: a toggleable row could be a checkbox or a switch, and
100        // Compose leaves that to the control inside it.
101        assert_eq!(semantics.role, None);
102    }
103}