Skip to main content

cranpose_ui/modifier/
toggleable.rs

1use super::{inspector_metadata, Modifier, SemanticsConfiguration};
2use cranpose_foundation::SemanticsWidgetRole;
3use std::rc::Rc;
4
5impl Modifier {
6    /// Make the component a two-state control.
7    ///
8    /// This is Compose's `Modifier.toggleable(value, enabled, role,
9    /// onValueChange)`: a click hands the callback the **new** value, so a
10    /// caller writes `.toggleable(checked, None, None, move |next|
11    /// state.set(next))` and never has to read the old one back out of its own
12    /// state to invert it.
13    ///
14    /// `role` is what a screen reader announces the control **as** — "switch",
15    /// "checkbox" — before it is acted on, and it is genuinely optional: a
16    /// toggleable row could be either, so Compose's parameter defaults to
17    /// `null` and so does passing `None` here. Wear's own `SwitchButton` leaves
18    /// it unset on the row and puts `Role.Switch` on the `Switch` control
19    /// inside, which merges up into the same node; naming it on the row reaches
20    /// the same announcement without leaning on a merge rule.
21    ///
22    /// A toggleable control with no role is not silent — the description and
23    /// the state below still speak — but it is announced as an unnamed
24    /// something the reader cannot say is toggleable, which is the whole of the
25    /// difference.
26    ///
27    /// The **state** it publishes regardless: `toggled`, Compose's
28    /// `toggleableState`, so a reader landing on the row says whether it is on
29    /// without the caller spelling it into the description. `description` stays
30    /// because a row still needs a name, and a caller that wants the state
31    /// spoken a particular way ("Haptics, on") can still say it there.
32    pub fn toggleable(
33        self,
34        value: bool,
35        description: Option<String>,
36        role: Option<SemanticsWidgetRole>,
37        on_value_change: impl Fn(bool) + 'static,
38    ) -> Self {
39        let on_value_change = Rc::new(on_value_change);
40        let toggled = value;
41        let modifier = Modifier::empty()
42            .clickable(move |_point| on_value_change(!toggled))
43            .with_inspector_metadata(inspector_metadata("toggleable", move |info| {
44                info.add_property("value", if toggled { "true" } else { "false" });
45                info.add_property("onValueChange", "provided");
46            }))
47            .then(
48                Modifier::empty().semantics(move |config: &mut SemanticsConfiguration| {
49                    config.is_clickable = true;
50                    config.toggled = Some(toggled);
51                    if let Some(description) = &description {
52                        config.content_description = Some(description.clone());
53                    }
54                    if let Some(role) = role {
55                        config.role = Some(role);
56                    }
57                }),
58            );
59        self.then(modifier)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::modifier::{collect_semantics_from_modifier, collect_slices_from_modifier, Point};
67    use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
68    use std::cell::Cell;
69
70    /// A click on a real chain is a pointer down followed by a pointer up: the
71    /// click fires on the release, and only then.
72    fn tap(modifier: &Modifier) {
73        let slices = collect_slices_from_modifier(modifier);
74        let handlers = slices.pointer_inputs();
75        assert_eq!(handlers.len(), 1, "toggleable takes pointer input once");
76        let at = Point { x: 4.0, y: 4.0 };
77        for kind in [PointerEventKind::Down, PointerEventKind::Up] {
78            let mut event = PointerEvent::new(kind, at, at);
79            event.buttons = PointerButtons::new().with(PointerButton::Primary);
80            handlers[0](event);
81        }
82    }
83
84    #[test]
85    fn a_click_reports_the_new_value_not_the_old_one() {
86        let _app_context = crate::render_state::app_context_test_scope();
87        for start in [false, true] {
88            let seen: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
89            let sink = seen.clone();
90            let modifier =
91                Modifier::empty().toggleable(start, None, None, move |next| sink.set(Some(next)));
92            tap(&modifier);
93            assert_eq!(
94                seen.get(),
95                Some(!start),
96                "a toggle hands over the value it is moving to"
97            );
98        }
99    }
100
101    #[test]
102    fn a_toggleable_row_reads_as_clickable_and_carries_its_description() {
103        let modifier =
104            Modifier::empty().toggleable(true, Some("Haptics, on".to_string()), None, |_| {});
105        let semantics = collect_semantics_from_modifier(&modifier)
106            .expect("a toggleable row publishes semantics");
107        assert!(semantics.is_clickable);
108        assert_eq!(
109            semantics.content_description.as_deref(),
110            Some("Haptics, on")
111        );
112        // The state is published, not left for the caller to spell into the
113        // description: a reader landing here can say the row is on.
114        assert_eq!(semantics.toggled, Some(true));
115        // And no role unless one is asked for: a toggleable row could be a
116        // checkbox or a switch, which is why Compose's parameter is nullable.
117        assert_eq!(semantics.role, None);
118    }
119
120    #[test]
121    fn a_role_reaches_the_semantics_so_a_reader_can_say_what_the_control_is() {
122        let modifier = Modifier::empty().toggleable(
123            false,
124            Some("Haptics, off".to_string()),
125            Some(SemanticsWidgetRole::Switch),
126            |_| {},
127        );
128        let semantics = collect_semantics_from_modifier(&modifier).expect("semantics");
129        assert_eq!(semantics.role, Some(SemanticsWidgetRole::Switch));
130        // And it does not displace anything the row already published.
131        assert!(semantics.is_clickable);
132        assert_eq!(semantics.toggled, Some(false));
133        assert_eq!(
134            semantics.content_description.as_deref(),
135            Some("Haptics, off")
136        );
137    }
138}