Skip to main content

cranpose_ui/layout/
semantics_labels.rs

1use std::borrow::Cow;
2
3use super::{SemanticsNode, SemanticsRole, SemanticsWidgetRole};
4
5#[expect(clippy::multiple_inherent_impl, reason = "labels in their own module")]
6impl SemanticsNode {
7    /// Whether this node combines its static descendants into one reader stop.
8    /// Independently operable descendants remain separate stops.
9    pub fn merges_accessibility_descendants(&self) -> bool {
10        let details = self.details();
11        self.merge_descendants
12            || !self.actions.is_empty()
13            || details.editable_text
14            || details.password
15            || self.focusable
16            || !details.custom_actions.is_empty()
17            || details.set_progress.is_some()
18            || details.set_text.is_some()
19            || details.set_selection.is_some()
20            || details.on_long_click.is_some()
21            || details.on_magic_tap.is_some()
22            || details.expand.is_some()
23            || details.collapse.is_some()
24            || details.dismiss.is_some()
25    }
26
27    /// Whether an ancestor must leave this node and its content as a separate
28    /// reader stop or container instead of including it in its own label.
29    pub fn is_accessibility_boundary(&self) -> bool {
30        let details = self.details();
31        self.merges_accessibility_descendants()
32            || details.vertical_scroll.is_some()
33            || details.horizontal_scroll.is_some()
34            || details.selectable_group
35            || details.pane_title.is_some()
36            || matches!(
37                self.widget_role,
38                Some(
39                    SemanticsWidgetRole::Dialog
40                        | SemanticsWidgetRole::Toolbar
41                        | SemanticsWidgetRole::Menu
42                        | SemanticsWidgetRole::TabBar
43                        | SemanticsWidgetRole::List
44                        | SemanticsWidgetRole::RadioGroup
45                )
46            )
47    }
48
49    /// Children in screen-reader order. Equal indices retain composition order;
50    /// non-finite indices are treated as the default index, zero. Children
51    /// that all keep the default index come in composition order without
52    /// being collected first.
53    pub fn accessibility_children(&self) -> impl Iterator<Item = &Self> + '_ {
54        let index = |node: &Self| {
55            if node.traversal_index.is_finite() {
56                node.traversal_index
57            } else {
58                0.0
59            }
60        };
61        if !self.children.iter().any(|child| index(child) != 0.0) {
62            return ReaderOrder::Composed(self.children.iter());
63        }
64        let mut children: Vec<_> = self.children.iter().collect();
65        children.sort_by(|left, right| {
66            index(left)
67                .partial_cmp(&index(right))
68                .unwrap_or(std::cmp::Ordering::Equal)
69        });
70        ReaderOrder::Sorted(children.into_iter())
71    }
72
73    /// The accessible name, including merged static descendants in reading
74    /// order. Hidden content and independent controls contribute no text to an
75    /// ancestor. Password values never become names. An unnamed editable field
76    /// returns an empty name so it remains reachable by assistive technology.
77    pub fn accessibility_label(&self) -> Option<Cow<'_, str>> {
78        if self.hidden {
79            return None;
80        }
81        if let Some(own) = self.own_accessibility_label() {
82            return Some(Cow::Borrowed(own));
83        }
84        let mut words = String::new();
85        if self.merges_accessibility_descendants() && self.write_accessibility_words(&mut words) {
86            return Some(Cow::Owned(words));
87        }
88        self.details().editable_text.then_some(Cow::Borrowed(""))
89    }
90
91    /// Appends the name [`SemanticsNode::accessibility_label`] gives to
92    /// `out`, without collecting a merged name first, and answers whether
93    /// the node has a name at all; an unnamed editable field has an empty one.
94    pub fn write_accessibility_label(&self, out: &mut String) -> bool {
95        if self.hidden {
96            return false;
97        }
98        if let Some(own) = self.own_accessibility_label() {
99            out.push_str(own);
100            return true;
101        }
102        (self.merges_accessibility_descendants() && self.write_accessibility_words(out))
103            || self.details().editable_text
104    }
105
106    fn own_accessibility_label(&self) -> Option<&str> {
107        if self.details().password {
108            return Some(
109                self.description
110                    .as_deref()
111                    .filter(|label| {
112                        !label.trim().is_empty() && Some(*label) != self.text.as_deref()
113                    })
114                    .unwrap_or("password"),
115            );
116        }
117        self.description
118            .as_deref()
119            .filter(|label| !label.trim().is_empty())
120            .or_else(|| match &self.role {
121                SemanticsRole::Text { value } if !value.as_str().trim().is_empty() => {
122                    Some(value.as_str())
123                }
124                _ => None,
125            })
126    }
127
128    fn write_accessibility_words(&self, out: &mut String) -> bool {
129        let mut wrote = false;
130        self.append_accessibility_words(out, &mut wrote);
131        wrote
132    }
133
134    fn append_accessibility_words(&self, out: &mut String, wrote: &mut bool) {
135        for child in self.accessibility_children() {
136            if child.hidden || child.is_accessibility_boundary() {
137                continue;
138            }
139            match child.own_accessibility_label() {
140                Some(label) if !label.trim().is_empty() => {
141                    if *wrote {
142                        out.push_str(", ");
143                    }
144                    out.push_str(label);
145                    *wrote = true;
146                }
147                _ => child.append_accessibility_words(out, wrote),
148            }
149        }
150    }
151}
152
153enum ReaderOrder<'a> {
154    Composed(std::slice::Iter<'a, SemanticsNode>),
155    Sorted(std::vec::IntoIter<&'a SemanticsNode>),
156}
157
158impl<'a> Iterator for ReaderOrder<'a> {
159    type Item = &'a SemanticsNode;
160
161    fn next(&mut self) -> Option<Self::Item> {
162        match self {
163            Self::Composed(children) => children.next(),
164            Self::Sorted(children) => children.next(),
165        }
166    }
167
168    fn size_hint(&self) -> (usize, Option<usize>) {
169        match self {
170            Self::Composed(children) => children.size_hint(),
171            Self::Sorted(children) => children.size_hint(),
172        }
173    }
174}
175
176#[cfg(test)]
177#[path = "tests/semantics_labels.rs"]
178mod tests;