Skip to main content

cranpose_ui/modifier/
toggleable.rs

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