frus_core/semantics.rs
1//! **Accessibility semantics**: the per-widget annotation (role, label, value,
2//! state) that the framework exposes to assistive technology.
3//!
4//! A frus-native, dependency-free type that **maps** onto `accesskit` at the
5//! platform edge (the shell builds an AccessKit tree out of these nodes). This
6//! follows the §14 advice: *bake the label into the widgets now, wire AccessKit up
7//! afterwards.*
8
9/// An element's semantic **role** (a subset aligned on AccessKit/ARIA roles: what a
10/// screen reader announces).
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
12pub enum Role {
13 /// An element with no role of its own (a layout container).
14 #[default]
15 None,
16 /// Static text (label, paragraph).
17 Label,
18 /// A heading or title.
19 Heading,
20 /// An actionable button.
21 Button,
22 /// A link.
23 Link,
24 /// A checkbox (`checked` state).
25 CheckBox,
26 /// A switch (`checked` state).
27 Switch,
28 /// A radio button (`checked` state).
29 RadioButton,
30 /// A continuous-value slider (`value` / `min` / `max`).
31 Slider,
32 /// A text input field (`value` = its contents).
33 TextInput,
34 /// An image or icon described by its `label`.
35 Image,
36 /// A tab.
37 Tab,
38 /// A list item.
39 ListItem,
40 /// A progress bar (`value`).
41 ProgressBar,
42}
43
44/// The checked state of a toggleable control (checkbox, switch, radio).
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
46pub enum Toggled {
47 /// Not applicable — the control does not toggle.
48 #[default]
49 None,
50 /// Unchecked.
51 False,
52 /// Checked.
53 True,
54 /// **Partly** checked: some of what this stands for is on and some is not.
55 ///
56 /// It is a third answer rather than a second no. A "select all" that reads
57 /// *unchecked* while three of its five rows are ticked is telling a reader
58 /// something false, and the platform's own vocabulary has the word for it.
59 Mixed,
60}
61
62/// A widget's **resolved** semantic annotation, for accessibility.
63#[derive(Clone, Debug, PartialEq, Default)]
64pub struct SemanticsProperties {
65 /// The announced role.
66 pub role: Role,
67 /// Accessible name (what the screen reader reads out).
68 pub label: Option<String>,
69 /// Textual value (a field's contents, a slider's position, and so on).
70 pub value: Option<String>,
71 /// Checked state (checkboxes, switches, radios).
72 pub toggled: Toggled,
73 /// Actionable by click — the screen reader offers "activate".
74 pub clickable: bool,
75 /// Disabled (greyed out, non-interactive).
76 pub disabled: bool,
77 /// Numeric bounds `(min, value, max)` for sliders and progress bars.
78 pub range: Option<(f32, f32, f32)>,
79}
80
81impl SemanticsProperties {
82 /// An annotation carrying the given role and nothing else.
83 pub fn new(role: Role) -> Self {
84 Self {
85 role,
86 ..Default::default()
87 }
88 }
89
90 /// Sets the accessible name.
91 pub fn label(mut self, label: impl Into<String>) -> Self {
92 self.label = Some(label.into());
93 self
94 }
95
96 /// Sets the textual value.
97 pub fn value(mut self, value: impl Into<String>) -> Self {
98 self.value = Some(value.into());
99 self
100 }
101
102 /// Marks the checked state.
103 pub fn toggled(mut self, on: bool) -> Self {
104 self.toggled = if on { Toggled::True } else { Toggled::False };
105 self
106 }
107
108 /// Marks it on, off, or **partly** on — `None` being the third answer, not a
109 /// missing one.
110 pub fn maybe_toggled(mut self, on: Option<bool>) -> Self {
111 self.toggled = match on {
112 Some(true) => Toggled::True,
113 Some(false) => Toggled::False,
114 None => Toggled::Mixed,
115 };
116 self
117 }
118
119 /// Marks the element as actionable.
120 pub fn clickable(mut self) -> Self {
121 self.clickable = true;
122 self
123 }
124
125 /// Marks the element as disabled.
126 pub fn disabled(mut self, disabled: bool) -> Self {
127 self.disabled = disabled;
128 self
129 }
130
131 /// Sets the numeric bounds `(min, value, max)`.
132 pub fn range(mut self, min: f32, value: f32, max: f32) -> Self {
133 self.range = Some((min, value, max));
134 self
135 }
136
137 /// `true` when this node carries something useful to assistive technology — a
138 /// non-null role or a label. Empty containers are left out of the tree.
139 pub fn is_meaningful(&self) -> bool {
140 self.role != Role::None || self.label.is_some()
141 }
142
143 /// This annotation laid **over** `under`, whose answers show through wherever this
144 /// one said nothing.
145 ///
146 /// What a container states about its child usually is not everything there is to
147 /// say: something that marks a widget as a heading knows the *role* and has no idea
148 /// what the words are. Dropping the child's answer would replace a spoken title with
149 /// silence, which is a worse outcome than the unlabelled heading it was fixing.
150 ///
151 /// **Two labels are joined, not chosen between** — one line each, as the reference
152 /// joins the labels of a merged subtree. A row holding a checkbox and its caption
153 /// reads as one control with both, and picking one would drop the other on the floor
154 /// with nothing to say which.
155 ///
156 /// Every other field is *this one's answer, or the other's where it had none*, with
157 /// each field's "none" being its own: `Role::None`, `Toggled::None`, a `false`
158 /// flag, an absent `Option`. `clickable` and `disabled` are unions rather than
159 /// choices, because either one being true is a fact about the whole.
160 #[must_use]
161 pub fn over(self, under: &Self) -> Self {
162 Self {
163 role: if self.role != Role::None {
164 self.role
165 } else {
166 under.role
167 },
168 label: match (self.label, under.label.clone()) {
169 (Some(mine), Some(theirs)) => Some(format!("{mine}\n{theirs}")),
170 (mine, theirs) => mine.or(theirs),
171 },
172 value: self.value.or_else(|| under.value.clone()),
173 toggled: if self.toggled != Toggled::None {
174 self.toggled
175 } else {
176 under.toggled
177 },
178 clickable: self.clickable || under.clickable,
179 disabled: self.disabled || under.disabled,
180 range: self.range.or(under.range),
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn builders_compose() {
191 let s = SemanticsProperties::new(Role::CheckBox)
192 .label("Notifications")
193 .toggled(true);
194 assert_eq!(s.role, Role::CheckBox);
195 assert_eq!(s.label.as_deref(), Some("Notifications"));
196 assert_eq!(s.toggled, Toggled::True);
197 assert!(s.is_meaningful());
198 }
199
200 #[test]
201 fn empty_is_not_meaningful() {
202 assert!(!SemanticsProperties::default().is_meaningful());
203 // A bare label is enough to be exposed.
204 assert!(SemanticsProperties::default().label("x").is_meaningful());
205 }
206}