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`] 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}