Skip to main content

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}