Skip to main content

frus_widgets/
semantics.rs

1//! [`Semantics`]: states what a widget **is**, from outside the widget.
2//!
3//! Every other widget in this crate answers [`Widget::semantics`] for *itself*. That
4//! covers the ordinary case and misses the one this exists for: a caller is handed a
5//! built widget and knows something about it that the widget does not know about itself.
6//!
7//! Milestone 397 walked into it. An [`AppBar`](crate::AppBar) marks its **text** title as
8//! a heading — a landmark a screen reader's user jumps between — and could do nothing of
9//! the sort for a *widget* title, because by then the title is a `Box<dyn Widget>` and the
10//! bar has no way in. The accessibility of a bar therefore depended on whether the caller
11//! had passed a string or a widget, which is not a distinction anybody using assistive
12//! technology should be able to feel.
13
14use frus_core::{Rect, Role, Scene, SemanticsProperties};
15use frus_layout::Style;
16
17use crate::interaction::Status;
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21/// What a [`Semantics`] wrapper asks the walk to do with its subtree's annotations.
22///
23/// Returned by [`Widget::describes`] and read in one place, the way a
24/// [`ModalBarrier`](crate::barrier::ModalBarrier) is: the subtree is walked exactly as
25/// usual and what it produced is reconciled afterwards. Doing it after rather than instead
26/// is what makes it exact — a widget deep inside annotates itself without knowing anything
27/// is speaking for it.
28#[derive(Clone, Debug, PartialEq)]
29pub struct Description {
30    /// What the wrapper says about its child.
31    pub props: SemanticsProperties,
32    /// Replace the subtree's own annotations with this one instead of adding to them.
33    pub merging: bool,
34}
35
36/// States a **role, a name or a state** for a child that cannot state it for itself — the
37/// reference's `Semantics` widget.
38///
39/// ```ignore
40/// // A widget title announced as a landmark rather than as one more run of prose.
41/// Semantics::heading(my_title_widget)
42///
43/// // A row of two controls that reads as one thing.
44/// Semantics::merge(Flex::row().child(Checkbox::new(on)).child(Text::new("Notifications")))
45/// ```
46///
47/// # It adds a node; merging replaces them
48///
49/// By default this **adds** an annotation and leaves the child's alone: safe, and what a
50/// caller naming an otherwise anonymous box wants. [`Semantics::merging`] is the other
51/// answer — the subtree's annotations are dropped and this one speaks for all of them,
52/// carrying over what they said through [`SemanticsProperties::over`].
53///
54/// The default is the additive one **because the destructive one cannot be undone by the
55/// caller**. Wrapping a whole screen to name it, under a merging default, would collapse
56/// every control on it into a single node; the reverse mistake leaves one node too many,
57/// which is noise rather than loss.
58///
59/// The reference splits the same two behaviours across `Semantics(excludeSemantics:)` and
60/// a separate `MergeSemantics`; [`Semantics::merge`] is that second widget, spelled as the
61/// constructor it is.
62///
63/// # What the flat tree costs
64///
65/// This framework's accessibility tree is **flat**: every annotated widget is a child of
66/// the window. So merging really does discard, where the reference merges a subtree into
67/// its container node and keeps the shape. Two labels are joined into one string, one line
68/// each, which is what the reference does with a merged subtree too — but a nested
69/// structure a reader could descend into is not something ours can express yet.
70pub struct Semantics<Msg = crate::callback::Callback> {
71    props: SemanticsProperties,
72    merging: bool,
73    children: Vec<Box<dyn Widget<Msg>>>,
74}
75
76impl<Msg> Semantics<Msg> {
77    /// Annotates `child` with `props`, adding a node and leaving the child's own alone.
78    pub fn new(props: SemanticsProperties, child: impl Widget<Msg> + 'static) -> Self {
79        Self {
80            props,
81            merging: false,
82            children: vec![Box::new(child)],
83        }
84    }
85
86    /// Announces `child` as a **heading**: a landmark assistive technology can jump
87    /// between, rather than one more run of prose.
88    ///
89    /// It merges, and that is the point of the shorthand. A heading that also announced
90    /// the text inside it as a separate label would be read twice — once as a landmark and
91    /// once as prose — so the wrapper takes the child's words and speaks for it.
92    pub fn heading(child: impl Widget<Msg> + 'static) -> Self {
93        Self::new(SemanticsProperties::new(Role::Heading), child).merging(true)
94    }
95
96    /// Makes `child`'s whole subtree read as **one thing** — the reference's
97    /// `MergeSemantics`, for a row of controls that is really a single control.
98    ///
99    /// It states nothing of its own: everything announced comes from the subtree, joined.
100    pub fn merge(child: impl Widget<Msg> + 'static) -> Self {
101        Self::new(SemanticsProperties::default(), child).merging(true)
102    }
103
104    /// Whether this speaks **for** the subtree (dropping its annotations and carrying over
105    /// what they said) or merely **beside** it. Off by default; see the type's docs for
106    /// why that is the safe default rather than the faithful one.
107    pub fn merging(mut self, merging: bool) -> Self {
108        self.merging = merging;
109        self
110    }
111
112    /// Sets the accessible name — what a screen reader reads out.
113    pub fn label(mut self, label: impl Into<String>) -> Self {
114        self.props.label = Some(label.into());
115        self
116    }
117
118    /// Sets the textual value: a field's contents, a slider's position.
119    pub fn value(mut self, value: impl Into<String>) -> Self {
120        self.props.value = Some(value.into());
121        self
122    }
123
124    /// Sets the announced role.
125    pub fn role(mut self, role: Role) -> Self {
126        self.props.role = role;
127        self
128    }
129
130    /// Marks it checked or unchecked.
131    pub fn toggled(mut self, on: bool) -> Self {
132        self.props = self.props.toggled(on);
133        self
134    }
135
136    /// Announces it as actionable — the reader offers "activate".
137    pub fn clickable(mut self, clickable: bool) -> Self {
138        self.props.clickable = clickable;
139        self
140    }
141
142    /// Announces it as disabled.
143    pub fn disabled(mut self, disabled: bool) -> Self {
144        self.props.disabled = disabled;
145        self
146    }
147}
148
149impl<Msg: Clone> Widget<Msg> for Semantics<Msg> {
150    /// A **transparent** box: the child's own style decides everything, and this one is
151    /// the pass-through a single-child wrapper has to be to change nothing that is drawn.
152    fn style(&self) -> Style {
153        Style::default()
154    }
155
156    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
157        &self.children
158    }
159
160    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
161
162    /// It states what its child **is**, never what it does: an annotation that swallowed a
163    /// click would put a hit target over the widget it was describing.
164    fn on_click(&self) -> Option<Msg> {
165        None
166    }
167
168    fn describes(&self) -> Option<Description> {
169        Some(Description {
170            props: self.props.clone(),
171            merging: self.merging,
172        })
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::{build_ui, Button, Flex, Runtime, Text, Ui};
180    use frus_core::Size as CoreSize;
181
182    fn built(root: &dyn Widget<()>) -> Ui<()> {
183        build_ui(
184            root,
185            CoreSize::new(300.0, 200.0),
186            &Runtime::default(),
187            &Theme::default(),
188        )
189    }
190
191    /// Every annotation the built tree carries, in walk order.
192    fn described(root: &dyn Widget<()>) -> Vec<(Role, Option<String>)> {
193        built(root)
194            .semantics()
195            .iter()
196            .map(|(_, _, s)| (s.role, s.label.clone()))
197            .collect()
198    }
199
200    /// **A caller can mark an assembled widget as a heading**, which is the gap milestone
201    /// 397 left: an app bar could do it for a text title and not for a widget one.
202    ///
203    /// And it keeps the words. A heading with no label is a landmark that announces
204    /// nothing on arrival — worse than the unmarked text it replaced, not better — so the
205    /// wrapper states the role and takes the label from what it wrapped.
206    #[test]
207    fn a_heading_takes_the_role_it_states_and_the_words_it_wrapped() {
208        let root: Box<dyn Widget<()>> = Box::new(Semantics::heading(Text::new("Settings")));
209        assert_eq!(
210            described(root.as_ref()),
211            vec![(Role::Heading, Some("Settings".to_string()))],
212            "one node: a heading that says the words"
213        );
214    }
215
216    /// Left alone it **adds**, and the child keeps its own voice.
217    ///
218    /// This is the half that makes the default safe. A caller naming a box gets a name and
219    /// still has everything inside it; a merging default would have silently collapsed a
220    /// whole screen into one node the first time somebody named one.
221    #[test]
222    fn without_merging_the_child_still_speaks() {
223        let root: Box<dyn Widget<()>> = Box::new(
224            Semantics::new(SemanticsProperties::default(), Text::new("Settings"))
225                .label("A section"),
226        );
227        let found = described(root.as_ref());
228        assert_eq!(found.len(), 2, "two nodes, not one: {found:?}");
229        assert!(found.contains(&(Role::Label, Some("Settings".to_string()))));
230        assert!(found.iter().any(|(_, l)| l.as_deref() == Some("A section")));
231    }
232
233    /// **Two labels are joined, not chosen between.** A row of a control and its caption
234    /// reads as one thing with both; picking one would drop the other with nothing to say
235    /// which was lost.
236    #[test]
237    fn merging_joins_the_labels_it_swallows() {
238        let root: Box<dyn Widget<()>> = Box::new(Semantics::merge(
239            Flex::row()
240                .child(Text::new("Notifications"))
241                .child(Text::new("On")),
242        ));
243        let found = described(root.as_ref());
244        assert_eq!(found.len(), 1, "one node: {found:?}");
245        assert_eq!(found[0].1.as_deref(), Some("Notifications\nOn"));
246    }
247
248    /// **A merged control stays operable.** Found testing #18 in a real browser and a
249    /// real screen reader: `Semantics::merge` keyed the surviving node on the
250    /// *wrapper's* id, which nothing routes a click through, so a button merged with
251    /// its own caption announced correctly and did nothing on activation — on every
252    /// platform, AccessKit desktop included, since the bug was here rather than in
253    /// either bridge. The merged node now carries the id of the one child that was
254    /// clickable, so a click, a keyboard `Enter`, or an assistive technology's own
255    /// activation all still reach it.
256    #[test]
257    fn merging_keeps_the_clickable_childs_id_so_it_can_still_be_activated() {
258        let root: Box<dyn Widget<()>> = Box::new(Semantics::merge(
259            Flex::row()
260                .child(Button::new("Save").on_press(()))
261                .child(Text::new("your changes")),
262        ));
263        let ui = built(root.as_ref());
264        let found = ui.semantics();
265        assert_eq!(found.len(), 1, "one node: {found:?}");
266        let (id, _, props) = &found[0];
267        assert_eq!(props.label.as_deref(), Some("Save\nyour changes"));
268        assert_eq!(
269            ui.msg_for(*id),
270            Some(()),
271            "the merged node's id is the button's own — the one thing in the \
272             subtree a click, Enter, or an AT's activation can actually be routed to"
273        );
274    }
275
276    /// **Two clickable children have no single answer**, so nothing is guessed: the
277    /// merged node falls back to the wrapper's own id, exactly as before this fix —
278    /// silently picking one of two buttons would be its own, quieter bug.
279    #[test]
280    fn merging_two_clickable_children_falls_back_to_the_wrappers_id() {
281        let root: Box<dyn Widget<()>> = Box::new(Semantics::merge(
282            Flex::row()
283                .child(Button::new("Save").on_press(()))
284                .child(Button::new("Cancel").on_press(())),
285        ));
286        let ui = built(root.as_ref());
287        let found = ui.semantics();
288        assert_eq!(found.len(), 1, "one node: {found:?}");
289        assert_eq!(ui.msg_for(found[0].0), None);
290    }
291
292    /// An annotation that says nothing and swallows nothing puts **no node** in the tree.
293    ///
294    /// The same rule every other widget is held to — `is_meaningful` — applied to the one
295    /// widget whose whole purpose is to add a node. A wrapper that announced an empty
296    /// container would be one more thing for a reader to step through on the way to the
297    /// content.
298    #[test]
299    fn an_empty_annotation_adds_nothing() {
300        let root: Box<dyn Widget<()>> = Box::new(Semantics::merge(crate::Container::new()));
301        assert!(described(root.as_ref()).is_empty());
302    }
303}