use std::fmt::Debug;
use cranpose_app_shell::AppShell;
use cranpose_core::{NodeId, collections::map::HashMap};
use cranpose_render_common::Renderer;
use cranpose_ui::{
Announcement, CollectionInfo, LayoutBox, LiveRegionMode, ProgressBarRangeInfo, ScrollAxisRange,
SemanticsAction, SemanticsNode, SemanticsRole, SemanticsWidgetRole,
};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct AccessibilityRect {
pub(crate) x: f32,
pub(crate) y: f32,
pub(crate) width: f32,
pub(crate) height: f32,
}
impl AccessibilityRect {
pub(crate) const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
y,
width,
height,
}
}
pub(crate) fn center(self) -> (f32, f32) {
(self.x + self.width * 0.5, self.y + self.height * 0.5)
}
fn is_visible(self) -> bool {
self.width > 0.0
&& self.height > 0.0
&& self.x.is_finite()
&& self.y.is_finite()
&& self.width.is_finite()
&& self.height.is_finite()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AccessibilityRole {
Button,
StaticText,
TextField,
Checkbox,
Switch,
RadioButton,
Tab,
Image,
Header,
Dialog,
DropdownList,
ValuePicker,
Link,
SearchField,
ProgressBar,
ToggleButton,
Alert,
Toolbar,
Menu,
MenuItem,
TabBar,
List,
ListItem,
}
const WIDGET_ROLES: [(SemanticsWidgetRole, AccessibilityRole); 21] = [
(SemanticsWidgetRole::Button, AccessibilityRole::Button),
(SemanticsWidgetRole::Checkbox, AccessibilityRole::Checkbox),
(SemanticsWidgetRole::Switch, AccessibilityRole::Switch),
(
SemanticsWidgetRole::RadioButton,
AccessibilityRole::RadioButton,
),
(SemanticsWidgetRole::Tab, AccessibilityRole::Tab),
(SemanticsWidgetRole::Image, AccessibilityRole::Image),
(
SemanticsWidgetRole::DropdownList,
AccessibilityRole::DropdownList,
),
(
SemanticsWidgetRole::ValuePicker,
AccessibilityRole::ValuePicker,
),
(SemanticsWidgetRole::Header, AccessibilityRole::Header),
(SemanticsWidgetRole::Dialog, AccessibilityRole::Dialog),
(SemanticsWidgetRole::Link, AccessibilityRole::Link),
(
SemanticsWidgetRole::SearchField,
AccessibilityRole::SearchField,
),
(
SemanticsWidgetRole::ProgressBar,
AccessibilityRole::ProgressBar,
),
(
SemanticsWidgetRole::ToggleButton,
AccessibilityRole::ToggleButton,
),
(SemanticsWidgetRole::Alert, AccessibilityRole::Alert),
(SemanticsWidgetRole::Toolbar, AccessibilityRole::Toolbar),
(SemanticsWidgetRole::Menu, AccessibilityRole::Menu),
(SemanticsWidgetRole::MenuItem, AccessibilityRole::MenuItem),
(SemanticsWidgetRole::TabBar, AccessibilityRole::TabBar),
(SemanticsWidgetRole::List, AccessibilityRole::List),
(SemanticsWidgetRole::ListItem, AccessibilityRole::ListItem),
];
const _: () = assert!(WIDGET_ROLES.len() == SemanticsWidgetRole::ListItem as usize + 1);
#[cfg(any(
test,
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
const ARIA_ROLES: [(AccessibilityRole, &str); 23] = [
(AccessibilityRole::Button, "button"),
(AccessibilityRole::StaticText, "generic"),
(AccessibilityRole::TextField, "textbox"),
(AccessibilityRole::Checkbox, "checkbox"),
(AccessibilityRole::Switch, "switch"),
(AccessibilityRole::RadioButton, "radio"),
(AccessibilityRole::Tab, "tab"),
(AccessibilityRole::Image, "img"),
(AccessibilityRole::Header, "heading"),
(AccessibilityRole::Dialog, "dialog"),
(AccessibilityRole::DropdownList, "combobox"),
(AccessibilityRole::ValuePicker, "spinbutton"),
(AccessibilityRole::Link, "link"),
(AccessibilityRole::SearchField, "searchbox"),
(AccessibilityRole::ProgressBar, "progressbar"),
(AccessibilityRole::ToggleButton, "button"),
(AccessibilityRole::Alert, "alert"),
(AccessibilityRole::Toolbar, "toolbar"),
(AccessibilityRole::Menu, "menu"),
(AccessibilityRole::MenuItem, "menuitem"),
(AccessibilityRole::TabBar, "tablist"),
(AccessibilityRole::List, "list"),
(AccessibilityRole::ListItem, "listitem"),
];
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
const ANDROID_ROLE_CODES: [(AccessibilityRole, i32); 23] = [
(AccessibilityRole::Button, 1),
(AccessibilityRole::StaticText, 2),
(AccessibilityRole::TextField, 3),
(AccessibilityRole::Checkbox, 4),
(AccessibilityRole::Switch, 5),
(AccessibilityRole::RadioButton, 6),
(AccessibilityRole::Tab, 7),
(AccessibilityRole::Image, 8),
(AccessibilityRole::Header, 9),
(AccessibilityRole::Dialog, 10),
(AccessibilityRole::DropdownList, 11),
(AccessibilityRole::ValuePicker, 12),
(AccessibilityRole::Link, 13),
(AccessibilityRole::SearchField, 14),
(AccessibilityRole::ProgressBar, 15),
(AccessibilityRole::ToggleButton, 16),
(AccessibilityRole::Alert, 17),
(AccessibilityRole::Toolbar, 18),
(AccessibilityRole::Menu, 19),
(AccessibilityRole::MenuItem, 20),
(AccessibilityRole::TabBar, 21),
(AccessibilityRole::List, 22),
(AccessibilityRole::ListItem, 23),
];
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn role_entry<T: Copy>(
table: &[(AccessibilityRole, T)],
role: AccessibilityRole,
fallback: T,
) -> T {
table
.iter()
.find(|(named, _)| *named == role)
.map_or(fallback, |(_, value)| *value)
}
impl AccessibilityRole {
pub(crate) const ALL: [Self; 23] = [
Self::Button,
Self::StaticText,
Self::TextField,
Self::Checkbox,
Self::Switch,
Self::RadioButton,
Self::Tab,
Self::Image,
Self::Header,
Self::Dialog,
Self::DropdownList,
Self::ValuePicker,
Self::Link,
Self::SearchField,
Self::ProgressBar,
Self::ToggleButton,
Self::Alert,
Self::Toolbar,
Self::Menu,
Self::MenuItem,
Self::TabBar,
Self::List,
Self::ListItem,
];
fn from_widget_role(role: SemanticsWidgetRole) -> Self {
WIDGET_ROLES
.iter()
.find(|(widget, _)| *widget == role)
.map_or(Self::StaticText, |(_, own)| *own)
}
#[cfg(any(
test,
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn aria_name(self) -> &'static str {
role_entry(&ARIA_ROLES, self, "generic")
}
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
pub(crate) fn android_code(self) -> i32 {
role_entry(&ANDROID_ROLE_CODES, self, 2)
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn is_text_field(self) -> bool {
matches!(self, Self::TextField | Self::SearchField)
}
pub(crate) fn is_named_container(self) -> bool {
matches!(self, Self::Toolbar | Self::Menu | Self::TabBar | Self::List)
}
}
const _: () = assert!(AccessibilityRole::ALL.len() == AccessibilityRole::ListItem as usize + 1);
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct AccessibilityElement {
pub(crate) node_id: NodeId,
pub(crate) canvas_key: Option<u64>,
pub(crate) label: String,
pub(crate) state_description: Option<String>,
pub(crate) click_label: Option<String>,
pub(crate) long_click_label: Option<String>,
pub(crate) magic_tap_label: Option<String>,
pub(crate) input_labels: Vec<String>,
pub(crate) language: Option<String>,
pub(crate) value: Option<String>,
pub(crate) bounds: AccessibilityRect,
pub(crate) role: AccessibilityRole,
pub(crate) clickable: bool,
pub(crate) selected: Option<bool>,
pub(crate) toggled: Option<bool>,
pub(crate) enabled: bool,
pub(crate) custom_actions: Vec<String>,
pub(crate) focusable: bool,
pub(crate) focused: bool,
pub(crate) live_region: Option<LiveRegionMode>,
pub(crate) progress: Option<ProgressBarRangeInfo>,
pub(crate) adjustable: bool,
pub(crate) vertical_scroll: Option<ScrollAxisRange>,
pub(crate) horizontal_scroll: Option<ScrollAxisRange>,
pub(crate) scroll_to_index: bool,
pub(crate) scroll_parent: Option<NodeId>,
pub(crate) collection: Option<CollectionInfo>,
pub(crate) collection_item: Option<CollectionItem>,
pub(crate) pane_title: Option<String>,
pub(crate) error: Option<String>,
pub(crate) password: bool,
pub(crate) expanded: Option<bool>,
pub(crate) dismissable: bool,
pub(crate) text_selection: Option<(usize, usize)>,
}
impl Default for AccessibilityElement {
fn default() -> Self {
Self {
node_id: 0,
canvas_key: None,
label: String::new(),
state_description: None,
click_label: None,
long_click_label: None,
magic_tap_label: None,
input_labels: Vec::new(),
language: None,
value: None,
bounds: AccessibilityRect::default(),
role: AccessibilityRole::StaticText,
clickable: false,
selected: None,
toggled: None,
enabled: true,
custom_actions: Vec::new(),
focusable: false,
focused: false,
live_region: None,
progress: None,
adjustable: false,
vertical_scroll: None,
horizontal_scroll: None,
scroll_to_index: false,
scroll_parent: None,
collection: None,
collection_item: None,
pane_title: None,
error: None,
password: false,
expanded: None,
dismissable: false,
text_selection: None,
}
}
}
#[cfg(any(
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
pub(crate) fn snapshot_if_changed<R>(
shell: &mut AppShell<R>,
seen_revision: &mut Option<u64>,
) -> Option<Vec<AccessibilityElement>>
where
R: Renderer,
R::Error: Debug,
{
let revision = shell.semantics_snapshot_revision();
if *seen_revision == Some(revision) {
return None;
}
*seen_revision = Some(revision);
Some(snapshot(shell))
}
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn snapshot<R>(shell: &mut AppShell<R>) -> Vec<AccessibilityElement>
where
R: Renderer,
R::Error: Debug,
{
if !shell.semantics_active() {
return Vec::new();
}
let mut bounds = HashMap::new();
let has_layout = shell.with_layout_tree(|layout_tree| match layout_tree {
Some(layout_tree) => {
collect_bounds(layout_tree.root(), &mut bounds);
true
}
None => false,
});
if !has_layout {
return Vec::new();
}
let Some(semantics_tree) = shell.semantics_tree() else {
return Vec::new();
};
project_semantics(semantics_tree.root(), &bounds)
}
#[cfg_attr(test, allow(dead_code))]
fn collect_bounds(root: &LayoutBox, bounds: &mut HashMap<NodeId, AccessibilityRect>) {
bounds.insert(
root.node_id,
AccessibilityRect::new(root.rect.x, root.rect.y, root.rect.width, root.rect.height),
);
for child in &root.children {
collect_bounds(child, bounds);
}
}
pub(crate) fn element_ids(elements: &[AccessibilityElement]) -> Vec<i32> {
let mut assigned: Vec<i32> = Vec::with_capacity(elements.len());
for element in elements {
let mut id = element_id(element.node_id, element.canvas_key);
while assigned.contains(&id) {
id = if id == i32::MAX { 1 } else { id + 1 };
}
assigned.push(id);
}
assigned
}
fn element_id(node_id: NodeId, canvas_key: Option<u64>) -> i32 {
let mixed = match canvas_key {
None => node_id as u64,
Some(key) => {
(node_id as u64)
.wrapping_mul(0x9e37_79b9_7f4a_7c15)
.rotate_left(17)
^ key.wrapping_mul(0xd6e8_feb8_6659_fd93)
}
};
((mixed & 0x7fff_ffff) as i32).max(1)
}
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
pub(crate) fn resolve_element_id(
elements: &[AccessibilityElement],
id: i32,
) -> Option<(NodeId, Option<u64>)> {
element_ids(elements)
.into_iter()
.zip(elements)
.find(|(assigned, _)| *assigned == id)
.map(|(_, element)| (element.node_id, element.canvas_key))
}
fn project_semantics(
root: &SemanticsNode,
bounds: &HashMap<NodeId, AccessibilityRect>,
) -> Vec<AccessibilityElement> {
let mut elements = Vec::new();
project_node(root, bounds, false, None, None, &mut elements);
elements
}
pub(crate) fn install_inspector<R: Renderer>(shell: &mut AppShell<R>, enabled: Option<bool>)
where
R::Error: Debug,
{
shell.set_inspector_projector(
enabled
.unwrap_or(cfg!(debug_assertions))
.then_some(inspector_nodes),
);
}
fn inspector_nodes(
layout: &cranpose_ui::LayoutTree,
semantics: &cranpose_ui::SemanticsTree,
) -> Vec<cranpose_app_shell::inspector::InspectorNode> {
let mut bounds = HashMap::new();
collect_bounds(layout.root(), &mut bounds);
project_semantics(semantics.root(), &bounds)
.into_iter()
.map(inspector_node)
.collect()
}
fn inspector_node(element: AccessibilityElement) -> cranpose_app_shell::inspector::InspectorNode {
let value = if element.password {
"[protected]"
} else {
element.value.as_deref().unwrap_or("")
};
let mut actions = Vec::new();
if element.clickable {
actions.push("Activate".to_string());
}
if element.adjustable {
actions.push("Adjust value".to_string());
}
if element.focusable {
actions.push("Focus".to_string());
}
if matches!(
element.role,
AccessibilityRole::TextField | AccessibilityRole::SearchField
) {
actions.push("Edit text".to_string());
}
actions.extend(element.custom_actions.iter().cloned());
actions.extend(element.long_click_label.iter().cloned());
actions.extend(element.magic_tap_label.iter().cloned());
let details = format!(
"Name: {}\nRole: {:?}\nValue: {}\nState: {}\nEnabled: {} Focused: {}\nSelected: {:?} Toggled: {:?}\nBounds: {:.1}, {:.1} {:.1} x {:.1}\nActions: {}\nLive: {:?}\nRange: {:?}\nError: {}",
element.label,
element.role,
value,
element.state_description.as_deref().unwrap_or(""),
element.enabled,
element.focused,
element.selected,
element.toggled,
element.bounds.x,
element.bounds.y,
element.bounds.width,
element.bounds.height,
actions.join(", "),
element.live_region,
element.progress,
element.error.as_deref().unwrap_or("")
);
cranpose_app_shell::inspector::InspectorNode {
node_id: element.node_id,
canvas_key: element.canvas_key,
bounds: cranpose_ui::Rect {
x: element.bounds.x,
y: element.bounds.y,
width: element.bounds.width,
height: element.bounds.height,
},
label: format!("{}, {:?}", element.label, element.role),
details,
focused: element.focused,
issue: element.label.is_empty() && (element.clickable || element.focusable),
}
}
fn project_node(
node: &SemanticsNode,
bounds: &HashMap<NodeId, AccessibilityRect>,
suppress_static_text: bool,
inherited_live_region: Option<LiveRegionMode>,
inherited_scroll: Option<NodeId>,
elements: &mut Vec<AccessibilityElement>,
) {
if node.hidden {
return;
}
let live_region = node.live_region.or(inherited_live_region);
let first_new = elements.len();
let clickable = node
.actions
.iter()
.any(|action| matches!(action, SemanticsAction::Click { .. }));
let actionable = clickable || node.editable_text;
let merges = node.merges_accessibility_descendants();
let boundary = node.is_accessibility_boundary();
let label = node.accessibility_label();
let rect = bounds.get(&node.node_id).copied().unwrap_or_default();
let container = is_container(node);
if let Some(label) = label
&& rect.is_visible()
&& (boundary || !suppress_static_text)
{
elements.push(element_for_node(
node,
rect,
label.into_owned(),
clickable,
live_region,
));
} else if publishes_unlabeled(node) && rect.is_visible() {
elements.push(element_for_node(
node,
rect,
String::new(),
clickable,
live_region,
));
} else if actionable && rect.is_visible() {
warn_unlabeled(node.node_id);
}
project_canvas_children(node, rect, live_region, elements);
for element in &mut elements[first_new..] {
element.scroll_parent = inherited_scroll;
}
let suppress_children = merges || (suppress_static_text && !boundary);
let scroll_for_children = if container {
Some(node.node_id)
} else {
inherited_scroll
};
project_children(
node,
bounds,
suppress_children,
live_region,
scroll_for_children,
elements,
);
}
fn is_container(node: &SemanticsNode) -> bool {
node.vertical_scroll.is_some()
|| node.horizontal_scroll.is_some()
|| node.selectable_group
|| node
.widget_role
.is_some_and(|role| AccessibilityRole::from_widget_role(role).is_named_container())
}
fn publishes_unlabeled(node: &SemanticsNode) -> bool {
is_container(node) || node.pane_title.is_some()
}
fn project_children(
node: &SemanticsNode,
bounds: &HashMap<NodeId, AccessibilityRect>,
suppress_static_text: bool,
live_region: Option<LiveRegionMode>,
scroll_for_children: Option<NodeId>,
elements: &mut Vec<AccessibilityElement>,
) {
let first_child = elements.len();
for child in node.accessibility_children() {
project_node(
child,
bounds,
suppress_static_text,
live_region,
scroll_for_children,
elements,
);
}
if node.selectable_group {
number_group(node.node_id, first_child, elements);
}
}
fn number_group(group: NodeId, first_child: usize, elements: &mut [AccessibilityElement]) {
let members: Vec<usize> = (first_child..elements.len())
.filter(|index| {
elements[*index].selected.is_some() && elements[*index].scroll_parent == Some(group)
})
.collect();
let count = members.len();
let Some(first) = members.first() else {
return;
};
let horizontal = members.get(1).is_none_or(|second| {
let (a, b) = (elements[*first].bounds, elements[*second].bounds);
(b.x - a.x).abs() >= (b.y - a.y).abs()
});
for (index, position) in members.iter().zip(1..) {
elements[*index].collection_item = Some(CollectionItem {
position,
count,
horizontal,
});
}
let (rows, columns) = if horizontal { (1, count) } else { (count, 1) };
if let Some(element) = elements
.iter_mut()
.find(|element| element.node_id == group && element.canvas_key.is_none())
{
element.collection = Some(CollectionInfo { rows, columns });
}
}
fn expansion(node: &SemanticsNode) -> Option<bool> {
node.collapse
.is_some()
.then_some(true)
.or_else(|| node.expand.is_some().then_some(false))
}
fn long_click_label(node: &SemanticsNode) -> Option<String> {
node.on_long_click.as_ref()?;
let named = node
.on_long_click_label
.clone()
.filter(|label| !label.trim().is_empty());
Some(named.unwrap_or_else(|| "long press".to_owned()))
}
fn magic_tap_label(node: &SemanticsNode) -> Option<String> {
node.on_magic_tap.as_ref()?;
let named = node
.on_magic_tap_label
.clone()
.filter(|label| !label.trim().is_empty());
Some(named.unwrap_or_else(|| "magic tap".to_owned()))
}
#[cfg(debug_assertions)]
fn warn_unlabeled(node_id: NodeId) {
thread_local! {
static WARNED: std::cell::RefCell<std::collections::HashSet<NodeId>> =
std::cell::RefCell::new(std::collections::HashSet::new());
}
if WARNED.with(|warned| warned.borrow_mut().insert(node_id)) {
log::warn!(
"accessibility: control {node_id} takes a click or text but has no label, so a screen reader has nothing to read for it; give it Modifier::content_description or text inside"
);
}
}
#[cfg(not(debug_assertions))]
fn warn_unlabeled(_node_id: NodeId) {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CollectionItem {
pub(crate) position: usize,
pub(crate) count: usize,
pub(crate) horizontal: bool,
}
fn element_for_node(
node: &SemanticsNode,
rect: AccessibilityRect,
label: String,
clickable: bool,
live_region: Option<LiveRegionMode>,
) -> AccessibilityElement {
let role = if let Some(role) = node.widget_role {
AccessibilityRole::from_widget_role(role)
} else if node.editable_text {
AccessibilityRole::TextField
} else if node.progress.is_some() {
AccessibilityRole::ProgressBar
} else if clickable || matches!(node.role, SemanticsRole::Button) {
AccessibilityRole::Button
} else {
AccessibilityRole::StaticText
};
AccessibilityElement {
node_id: node.node_id,
canvas_key: None,
value: node
.text
.clone()
.or_else(|| node.editable_text.then(|| label.clone()))
.filter(|_| !node.password),
label,
state_description: node.state_description.clone(),
click_label: node.on_click_label.clone(),
long_click_label: long_click_label(node),
magic_tap_label: magic_tap_label(node),
input_labels: node.input_labels.clone(),
language: node.language.clone(),
bounds: rect,
role,
clickable: clickable && node.enabled,
selected: node.selected,
toggled: node.toggled,
enabled: node.enabled,
custom_actions: node
.custom_actions
.iter()
.map(|action| action.label.clone())
.collect(),
focusable: node.focusable,
focused: node.focused,
live_region: live_region.or_else(|| {
(node.widget_role == Some(SemanticsWidgetRole::Alert))
.then_some(LiveRegionMode::Assertive)
}),
progress: node.progress,
adjustable: node.set_progress.is_some(),
vertical_scroll: node.vertical_scroll,
horizontal_scroll: node.horizontal_scroll,
scroll_to_index: node.scroll_to_index.is_some(),
scroll_parent: None,
collection: node.collection,
collection_item: None,
pane_title: node.pane_title.clone(),
error: node.error.clone(),
password: node.password,
expanded: expansion(node),
dismissable: node.dismiss.is_some(),
text_selection: node
.text_selection
.filter(|_| node.editable_text && !node.password)
.map(|range| (range.start, range.end)),
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn scroll_by(root: &SemanticsNode, node_id: NodeId, dx: f32, dy: f32) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.scroll_by {
Some(action) => action.invoke(dx, dy),
None => false,
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn scroll_to_index(root: &SemanticsNode, node_id: NodeId, index: usize) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.scroll_to_index {
Some(action) => action.invoke(index),
None => false,
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn row_count(element: &AccessibilityElement) -> usize {
if !element.scroll_to_index {
return 0;
}
element
.collection
.map_or(0, |collection| collection.rows.max(collection.columns))
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn page_delta(element: &AccessibilityElement, forward: bool) -> (f32, f32) {
let sign = if forward { 1.0 } else { -1.0 };
if element.vertical_scroll.is_some() {
(0.0, sign * element.bounds.height * 0.9)
} else {
(sign * element.bounds.width * 0.9, 0.0)
}
}
#[cfg(any(
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn run_reader_action<R>(
shell: &mut AppShell<R>,
act: impl FnOnce(&SemanticsNode) -> bool,
) -> bool
where
R: Renderer,
R::Error: Debug,
{
let context = std::rc::Rc::clone(shell.app_context());
let changed = context.enter(|| {
cranpose_core::run_in_mutable_snapshot(|| {
shell.semantics_tree().is_some_and(|tree| act(tree.root()))
})
.unwrap_or(false)
});
if changed {
shell.mark_dirty();
}
changed
}
#[cfg(all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"))]
pub(crate) fn escape_has_a_taker() -> bool {
cranpose_ui::modal_depth() > 0 || cranpose_services::back_interception_enabled()
}
#[cfg(all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"))]
pub(crate) fn request_back() -> bool {
if !cranpose_services::back_interception_enabled() {
return false;
}
cranpose_services::push_back_request();
true
}
#[cfg(any(
test,
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn scroll_container_for<'a>(
elements: &'a [AccessibilityElement],
element: &AccessibilityElement,
) -> Option<&'a AccessibilityElement> {
let mut parent = element.scroll_parent;
while let Some(id) = parent {
let candidate = elements
.iter()
.find(|candidate| candidate.node_id == id && candidate.canvas_key.is_none())?;
if candidate.vertical_scroll.is_some() || candidate.horizontal_scroll.is_some() {
return Some(candidate);
}
parent = candidate.scroll_parent;
}
None
}
fn project_canvas_children(
node: &SemanticsNode,
owner: AccessibilityRect,
live_region: Option<LiveRegionMode>,
elements: &mut Vec<AccessibilityElement>,
) {
for child in &node.canvas_children {
let rect = AccessibilityRect::new(
owner.x + child.bounds.x,
owner.y + child.bounds.y,
child.bounds.width,
child.bounds.height,
);
if !rect.is_visible() || child.label.trim().is_empty() {
continue;
}
let role = match child.role {
Some(role) => AccessibilityRole::from_widget_role(role),
None if child.clickable => AccessibilityRole::Button,
None => AccessibilityRole::StaticText,
};
elements.push(AccessibilityElement {
node_id: node.node_id,
canvas_key: Some(child.key),
label: child.label.clone(),
state_description: child.state_description.clone(),
click_label: child.on_click_label.clone(),
bounds: rect,
role,
clickable: child.clickable && node.enabled && child.enabled,
selected: child.selected,
toggled: child.toggled,
enabled: node.enabled && child.enabled,
custom_actions: child
.custom_actions
.iter()
.map(|action| action.label.clone())
.collect(),
live_region,
..AccessibilityElement::default()
});
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn perform_custom_action(
root: &SemanticsNode,
node_id: NodeId,
canvas_key: Option<u64>,
action_index: usize,
) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
let actions = match canvas_key {
Some(key) => match node.canvas_children.iter().find(|child| child.key == key) {
Some(child) if child.enabled => &child.custom_actions,
Some(_) => return false,
None => return false,
},
None => &node.custom_actions,
};
match actions.get(action_index) {
Some(action) => {
action.invoke();
true
}
None if canvas_key.is_none() => {
let after = action_index - actions.len();
match (after, &node.on_long_click, &node.on_magic_tap) {
(0, Some(long_click), _) => long_click.invoke(),
(0, None, Some(tap)) | (1, Some(_), Some(tap)) => tap.invoke(),
_ => false,
}
}
None => false,
}
}
#[cfg(any(
test,
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios")
))]
pub(crate) fn magic_tap(root: &SemanticsNode, node_id: NodeId) -> bool {
find_semantics_node(root, node_id)
.is_some_and(|node| node.on_magic_tap.as_ref().is_some_and(|tap| tap.invoke()))
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn reader_actions(element: &AccessibilityElement) -> Vec<String> {
if !element.enabled {
return Vec::new();
}
element
.custom_actions
.iter()
.cloned()
.chain(element.long_click_label.clone())
.chain(element.magic_tap_label.clone())
.collect()
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn set_progress(root: &SemanticsNode, node_id: NodeId, value: f32) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.set_progress {
Some(action) => action.invoke(value),
None => false,
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn set_text(root: &SemanticsNode, node_id: NodeId, text: &str) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.set_text {
Some(action) => action.invoke(text),
None => false,
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn set_text_selection(
root: &SemanticsNode,
node_id: NodeId,
anchor: usize,
focus: usize,
) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.set_selection {
Some(action) => action.invoke(anchor, focus),
None => false,
}
}
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn set_text_selection_utf16(
root: &SemanticsNode,
node_id: NodeId,
anchor: usize,
focus: usize,
) -> bool {
set_text_selection_counted(root, node_id, anchor, focus, byte_offset_for_utf16)
}
#[cfg(any(test, all(feature = "desktop-shell", feature = "renderer-wgpu")))]
pub(crate) fn set_text_selection_chars(
root: &SemanticsNode,
node_id: NodeId,
anchor: usize,
focus: usize,
) -> bool {
set_text_selection_counted(root, node_id, anchor, focus, byte_offset_for_chars)
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
fn set_text_selection_counted(
root: &SemanticsNode,
node_id: NodeId,
anchor: usize,
focus: usize,
byte_offset: fn(&str, usize) -> usize,
) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
let text = node.text.as_deref().unwrap_or("");
set_text_selection(
root,
node_id,
byte_offset(text, anchor),
byte_offset(text, focus),
)
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
fn floor_char_boundary(text: &str, byte: usize) -> usize {
let mut byte = byte.min(text.len());
while !text.is_char_boundary(byte) {
byte -= 1;
}
byte
}
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn utf16_offset(text: &str, byte: usize) -> usize {
text[..floor_char_boundary(text, byte)]
.encode_utf16()
.count()
}
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn byte_offset_for_utf16(text: &str, units: usize) -> usize {
let mut seen = 0;
for (byte, character) in text.char_indices() {
if seen >= units {
return byte;
}
seen += character.len_utf16();
}
text.len()
}
#[cfg(any(test, all(feature = "desktop-shell", feature = "renderer-wgpu")))]
pub(crate) fn char_offset(text: &str, byte: usize) -> usize {
text[..floor_char_boundary(text, byte)].chars().count()
}
#[cfg(any(test, all(feature = "desktop-shell", feature = "renderer-wgpu")))]
pub(crate) fn byte_offset_for_chars(text: &str, characters: usize) -> usize {
text.char_indices()
.nth(characters)
.map_or(text.len(), |(byte, _)| byte)
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
pub(crate) fn set_expanded(root: &SemanticsNode, node_id: NodeId, open: bool) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
let action = if open { &node.expand } else { &node.collapse };
match action {
Some(action) => action.invoke(),
None => false,
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn dismiss(root: &SemanticsNode, node_id: NodeId) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.dismiss {
Some(action) => action.invoke(),
None => false,
}
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) const DISMISS_LABEL: &str = "Dismiss";
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn listed_actions(element: &AccessibilityElement) -> Vec<String> {
reader_actions(element)
.into_iter()
.chain((element.enabled && element.dismissable).then(|| DISMISS_LABEL.to_owned()))
.collect()
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn perform_listed_action(
root: &SemanticsNode,
node_id: NodeId,
canvas_key: Option<u64>,
named: usize,
index: usize,
) -> bool {
if index == named {
return dismiss(root, node_id);
}
perform_custom_action(root, node_id, canvas_key, index)
}
#[cfg(any(
test,
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
pub(crate) fn long_click(root: &SemanticsNode, node_id: NodeId) -> bool {
let Some(node) = find_semantics_node(root, node_id) else {
return false;
};
match &node.on_long_click {
Some(action) => action.invoke(),
None => false,
}
}
#[cfg(any(
test,
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios")
))]
pub(crate) fn expansion_word(element: &AccessibilityElement) -> Option<&'static str> {
element
.expanded
.map(|open| if open { "expanded" } else { "collapsed" })
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios")
))]
pub(crate) fn stepped_value(progress: &ProgressBarRangeInfo, up: bool) -> f32 {
let step = progress.step();
let next = if up {
progress.current + step
} else {
progress.current - step
};
let low = progress.start.min(progress.end);
let high = progress.start.max(progress.end);
next.clamp(low, high)
}
pub(crate) fn focus_node(node_id: NodeId) -> bool {
cranpose_ui::request_focus_from_platform(node_id)
}
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn apply_accessibility_options<R>(
shell: &mut AppShell<R>,
options: cranpose_services::AccessibilityOptions,
) -> bool
where
R: Renderer,
R::Error: Debug,
{
let changed = cranpose_services::set_platform_accessibility_options(options);
if changed {
shell.request_root_render();
}
changed
}
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn drain_app_announcements() -> Vec<Announcement> {
cranpose_ui::drain_announcements()
}
#[cfg(any(
test,
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn live_region_announcements(
previous: &[AccessibilityElement],
current: &[AccessibilityElement],
) -> Vec<Announcement> {
if previous.is_empty() {
return Vec::new();
}
let mut announcements = Vec::new();
for element in current {
let Some(mode) = element.live_region else {
continue;
};
let text = spoken_text(element);
if text.trim().is_empty() {
continue;
}
let was = previous
.iter()
.find(|other| {
other.node_id == element.node_id && other.canvas_key == element.canvas_key
})
.map(spoken_text);
if was.as_deref() != Some(text.as_str()) {
announcements.push(Announcement { text, mode });
}
}
announcements
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn pane_title_announcements(
previous: &[AccessibilityElement],
current: &[AccessibilityElement],
) -> Vec<Announcement> {
if previous.is_empty() {
return Vec::new();
}
current
.iter()
.filter_map(|element| {
let title = element
.pane_title
.as_deref()
.filter(|title| !title.trim().is_empty())?;
let was = previous
.iter()
.find(|other| other.node_id == element.node_id)
.and_then(|other| other.pane_title.as_deref());
(was != Some(title)).then(|| Announcement {
text: title.to_owned(),
mode: LiveRegionMode::Polite,
})
})
.collect()
}
#[cfg(any(
test,
feature = "robot",
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
fn spoken_text(element: &AccessibilityElement) -> String {
let mut parts = vec![element.label.clone()];
if let Some(value) = &element.value
&& value != &element.label
{
parts.push(value.clone());
}
if let Some(state) = &element.state_description {
parts.push(state.clone());
}
parts.extend(error_text(element));
parts.retain(|part| !part.trim().is_empty());
parts.join(", ")
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn error_text(element: &AccessibilityElement) -> Option<String> {
element
.error
.as_deref()
.filter(|error| !error.trim().is_empty())
.map(|error| format!("invalid, {error}"))
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
pub(crate) fn state_with_error(element: &AccessibilityElement) -> Option<String> {
let parts: Vec<String> = element
.state_description
.clone()
.into_iter()
.chain(error_text(element))
.collect();
(!parts.is_empty()).then(|| parts.join(", "))
}
#[cfg(any(
test,
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
fn speaks_the_same(was: &AccessibilityElement, now: &AccessibilityElement) -> bool {
spoken_text(was) == spoken_text(now)
&& was.toggled == now.toggled
&& was.selected == now.selected
&& was.progress == now.progress
}
#[cfg(any(
test,
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android")
))]
pub(crate) fn spoken_changes(
previous: &[AccessibilityElement],
current: &[AccessibilityElement],
) -> Vec<bool> {
current
.iter()
.map(|element| {
previous
.iter()
.find(|other| {
other.node_id == element.node_id && other.canvas_key == element.canvas_key
})
.is_some_and(|was| !speaks_the_same(was, element))
})
.collect()
}
#[cfg(any(
test,
all(feature = "desktop-shell", feature = "renderer-wgpu"),
all(feature = "ios", feature = "renderer-wgpu", target_os = "ios"),
all(feature = "android", feature = "renderer-wgpu", target_os = "android"),
all(feature = "web", feature = "renderer-wgpu", target_arch = "wasm32")
))]
fn find_semantics_node(node: &SemanticsNode, node_id: NodeId) -> Option<&SemanticsNode> {
if node.hidden {
return None;
}
if node.node_id == node_id {
return node.enabled.then_some(node);
}
node.children
.iter()
.find_map(|child| find_semantics_node(child, node_id))
}
#[cfg(test)]
pub(crate) fn element_with(node_id: NodeId, canvas_key: Option<u64>) -> AccessibilityElement {
AccessibilityElement {
node_id,
canvas_key,
label: "Row".into(),
bounds: AccessibilityRect::new(0.0, 0.0, 10.0, 10.0),
..AccessibilityElement::default()
}
}
#[cfg(any(test, feature = "robot", target_os = "ios"))]
const SPOKEN_ROLES: [(AccessibilityRole, &str); 23] = [
(AccessibilityRole::Button, "button"),
(AccessibilityRole::StaticText, ""),
(AccessibilityRole::TextField, "text field"),
(AccessibilityRole::Checkbox, "checkbox"),
(AccessibilityRole::Switch, "switch"),
(AccessibilityRole::RadioButton, "radio button"),
(AccessibilityRole::Tab, "tab"),
(AccessibilityRole::Image, "image"),
(AccessibilityRole::Header, "heading"),
(AccessibilityRole::Dialog, "dialog"),
(AccessibilityRole::DropdownList, "pop up button"),
(AccessibilityRole::ValuePicker, "picker"),
(AccessibilityRole::Link, "link"),
(AccessibilityRole::SearchField, "search field"),
(AccessibilityRole::ProgressBar, "progress bar"),
(AccessibilityRole::ToggleButton, "toggle button"),
(AccessibilityRole::Alert, "alert"),
(AccessibilityRole::Toolbar, "toolbar"),
(AccessibilityRole::Menu, "menu"),
(AccessibilityRole::MenuItem, "menu item"),
(AccessibilityRole::TabBar, "tab bar"),
(AccessibilityRole::List, "list"),
(AccessibilityRole::ListItem, "list item"),
];
#[cfg(any(test, feature = "robot", target_os = "ios"))]
pub(crate) fn spoken_line(element: &AccessibilityElement) -> String {
let role_word = SPOKEN_ROLES
.iter()
.find(|(role, _)| *role == element.role)
.map(|(_, word)| *word)
.unwrap_or("");
let name = match (&element.pane_title, element.label.is_empty()) {
(Some(title), true) => format!("{title}, pane"),
_ => spoken_text(element),
};
let toggle_words = if element.role == AccessibilityRole::Switch {
("on", "off")
} else {
("checked", "not checked")
};
let actions: Vec<&str> = element
.custom_actions
.iter()
.map(String::as_str)
.chain(element.long_click_label.as_deref())
.chain(element.magic_tap_label.as_deref())
.collect();
let mut parts: Vec<String> = vec![name, role_word.to_string()];
parts.extend(
element
.toggled
.map(|on| if on { toggle_words.0 } else { toggle_words.1 }.to_string()),
);
parts.extend(
element
.selected
.filter(|picked| *picked)
.map(|_| "selected".to_string()),
);
parts.extend(element.expanded.map(|open| {
if open {
"expanded".to_string()
} else {
"collapsed".to_string()
}
}));
if element.state_description.is_none() {
parts.extend(element.progress.as_ref().and_then(spoken_percent));
}
parts.extend((!element.enabled).then(|| "dimmed".to_string()));
parts.extend(element.focused.then(|| "focused".to_string()));
parts.extend((!actions.is_empty()).then(|| format!("actions: {}", actions.join(", "))));
parts.retain(|part| !part.is_empty());
parts.join(", ")
}
#[cfg(target_os = "ios")]
pub(crate) fn log_spoken_tree(elements: &[AccessibilityElement]) {
const TARGET: &str = "cranpose::spoken_tree";
if !log::log_enabled!(target: TARGET, log::Level::Debug) {
return;
}
log::debug!(target: TARGET, "--- {} controls ---", elements.len());
for line in elements
.iter()
.map(spoken_line)
.filter(|line| !line.is_empty())
{
log::debug!(target: TARGET, "{line}");
}
}
#[cfg(any(test, feature = "robot", target_os = "ios"))]
fn spoken_percent(progress: &ProgressBarRangeInfo) -> Option<String> {
let span = progress.end - progress.start;
(span > 0.0).then(|| {
let percent = ((progress.current - progress.start) / span * 100.0).round();
format!("{percent} percent")
})
}
#[cfg(feature = "robot")]
pub(crate) fn spoken_tree<R>(shell: &mut AppShell<R>) -> String
where
R: Renderer,
R::Error: Debug,
{
snapshot(shell)
.iter()
.map(spoken_line)
.filter(|line| !line.is_empty())
.fold(String::new(), |mut tree, line| {
tree.push_str(&line);
tree.push('\n');
tree
})
}
#[cfg(test)]
#[path = "tests/accessibility.rs"]
mod tests;