use accesskit::{Action, HasPopup, Node as Node_, Role as Ak, Toggled};
use bevy::a11y::{AccessibilityNode, AccessibilityRequested};
use bevy::ecs::query::QueryData;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use bevy::ui::{Checked, InteractionDisabled, Selected};
use crate::access_actions::{Handles, MANAGED, TREE_BRANCH, actions_of};
use crate::binding::Capturing;
use crate::choice::ChoiceValue;
use crate::color::ColorSwatch;
use crate::cooldown::Cooldown;
use crate::menu::{MenuItem, MenuOpen};
use crate::meter::Meter;
use crate::model::cooldown_words;
use crate::progress::Progress;
use crate::section::{SectionHeader, SectionOpen};
use crate::skin::Role;
use crate::split::{SplitDivider, SplitFraction};
use crate::table::{Table, TableRow};
use crate::text::{AreaField, TextInputOf, TextInputValue};
use crate::toast::{Toast, ToastKind};
use crate::tooltip::Tooltip;
use crate::tree::{Tree, TreeView};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Spec {
Hidden,
Group,
Node,
}
fn spec(role: Role) -> Spec {
match role {
Role::Screen
| Role::Panel
| Role::Surface(_)
| Role::Tone(_)
| Role::Hovered
| Role::Pressed
| Role::Held
| Role::HeldGround
| Role::Unchecked
| Role::Card
| Role::PreviewEdge
| Role::Menu
| Role::Popover
| Role::ToastStack
| Role::ToastBody
| Role::Prompts
| Role::Prompt
| Role::ShortcutLegend
| Role::List
| Role::ListView
| Role::ListRows
| Role::TableView
| Role::TableRows
| Role::TableHeader
| Role::Split
| Role::SplitPane
| Role::Dock
| Role::DockBody
| Role::Tabs
| Role::TabList
| Role::TabPages
| Role::TextInput
| Role::TextArea
| Role::TextCell
| Role::NumberPart
| Role::NumberParts
| Role::ColorStripBox
| Role::Section
| Role::SectionBody
| Role::StatusGroup
| Role::StatusItem
| Role::StatusBar
| Role::Inspector
| Role::NotificationCenter
| Role::Palette
| Role::PaletteField
| Role::SlotGrid
| Role::FieldMessage => Spec::Group,
Role::Heading
| Role::Label
| Role::Button
| Role::Meter
| Role::Tooltip
| Role::Scrim
| Role::Toggle
| Role::Slider
| Role::Choice
| Role::Binding
| Role::PromptKey
| Role::PromptButton
| Role::Toast
| Role::ToastAction
| Role::Table
| Role::TableRow
| Role::TableCell
| Role::TableHeaderCell
| Role::TableEdge
| Role::Tab
| Role::TabPage
| Role::TextField
| Role::ColorSwatch
| Role::ColorArea
| Role::Viewport
| Role::SplitDivider
| Role::MenuBar
| Role::MenuPanel
| Role::ToggleGroup
| Role::MenuTitle
| Role::MenuItem
| Role::MenuNote
| Role::PaletteRow
| Role::PaletteCount
| Role::Progress
| Role::Spinner
| Role::Toolbar
| Role::GroupOption
| Role::Slot
| Role::SlotEmpty
| Role::Radial
| Role::SectionNote => Spec::Node,
Role::ButtonText
| Role::MenuTitleText
| Role::MeterFill
| Role::MeterTrail
| Role::ToggleTrack
| Role::ToggleKnob
| Role::SliderHeader
| Role::SliderTrack
| Role::SliderRail
| Role::SliderFill
| Role::SliderGap
| Role::SliderThumb
| Role::SliderReadout
| Role::ControlText
| Role::ChoiceArrow
| Role::ChoiceBack
| Role::ChoiceForward
| Role::ChoiceCell
| Role::ChoiceOption
| Role::BindingCap
| Role::BindingText
| Role::BindingPrompt
| Role::ShortcutBadge
| Role::ToastMark
| Role::ToastText
| Role::ListRail
| Role::ListThumb
| Role::TableSortUp
| Role::TableSortDown
| Role::TableCellText
| Role::TreeTwisty
| Role::TreeClosed
| Role::TreeOpen
| Role::SplitGrip
| Role::DockDrop
| Role::DockGhost
| Role::DragGhost
| Role::DragGhostRefused
| Role::DropMark
| Role::DropMarkRefused
| Role::TabText
| Role::TextHint
| Role::MenuMark
| Role::MenuCheck
| Role::MenuDot
| Role::MenuText
| Role::MenuShortcut
| Role::MenuChevron
| Role::ColorSwatchHalf
| Role::ColorAreaMarker
| Role::ColorAreaMarkerInner
| Role::ColorRail
| Role::Icon
| Role::FieldMark
| Role::ProgressLine
| Role::StatusMark
| Role::MenuSeparator
| Role::ToolbarSeparator
| Role::RadialTrack
| Role::SlotName
| Role::SlotCount
| Role::SlotIcon
| Role::Cooldown
| Role::CooldownText
| Role::HoldTrack
| Role::HoldFill
| Role::RadialIcon
| Role::RadialSlot
| Role::RadialText
| Role::RadialCentre
| Role::ToastActionText => Spec::Hidden,
}
}
fn names(role: Role) -> bool {
matches!(
role,
Role::ButtonText
| Role::MenuTitleText
| Role::TabText
| Role::MenuText
| Role::TableCellText
| Role::ToastActionText
)
}
#[derive(Default, Debug, PartialEq)]
struct Wanted {
role: Option<Ak>,
name: Option<String>,
value: Option<String>,
numeric: Option<(f64, f64, f64)>,
toggled: Option<bool>,
selected: Option<bool>,
expanded: Option<bool>,
tree_branch: bool,
end_node: bool,
level: Option<usize>,
position_in_set: Option<usize>,
size_of_set: Option<usize>,
row_index: Option<usize>,
row_count: Option<usize>,
column_count: Option<usize>,
popup: bool,
modal: bool,
hidden: bool,
disabled: bool,
actions: &'static [Action],
}
const DIVIDER: &str = "Divider";
const COLUMN_EDGE: &str = "Resize column";
const EMPTY_SLOT: &str = "Empty slot";
const COLOUR_AREA: &str = "Saturation and brightness";
const COMMAND_FIELD: &str = "Command";
#[derive(QueryData)]
pub(crate) struct Facts {
label: Option<&'static AccessibleLabel>,
seen: Option<&'static InheritedVisibility>,
node: Option<&'static Node>,
disabled: Has<InteractionDisabled>,
selected: Has<Selected>,
checked: Has<Checked>,
open: Has<MenuOpen>,
capturing: Has<Capturing>,
header: Has<SectionHeader>,
area: Has<AreaField>,
item: Option<&'static MenuItem>,
toast: Option<&'static Toast>,
meter: Option<&'static Meter>,
progress: Option<&'static Progress>,
row: Option<&'static TableRow>,
field: Option<&'static TextInputOf>,
swatch: Option<&'static ColorSwatch>,
choice: Option<&'static ChoiceValue>,
divider: Option<&'static SplitDivider>,
cooldown: Option<&'static Cooldown>,
handles: Handles,
}
#[derive(SystemParam)]
pub(crate) struct Look<'w, 's> {
texts: Query<'w, 's, &'static Text>,
roles: Query<'w, 's, &'static Role>,
children: Query<'w, 's, &'static Children>,
parents: Query<'w, 's, &'static ChildOf>,
values: Query<'w, 's, &'static TextInputValue>,
sections: Query<'w, 's, &'static SectionOpen>,
fractions: Query<'w, 's, &'static SplitFraction>,
trees: Query<'w, 's, (), With<TreeView>>,
tables: Query<'w, 's, (&'static Table, Has<InteractionDisabled>)>,
tooltips: Query<'w, 's, &'static Tooltip>,
}
impl Look<'_, '_> {
fn words(&self, root: Entity, want: impl Fn(Role) -> bool + Copy) -> Option<String> {
let mut out = String::new();
self.collect(root, want, &mut out);
let trimmed = out.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn collect(&self, from: Entity, want: impl Fn(Role) -> bool + Copy, out: &mut String) {
let Ok(children) = self.children.get(from) else {
return;
};
for child in children {
let Ok(&role) = self.roles.get(*child) else {
self.collect(*child, want, out);
continue;
};
if matches!(
role,
Role::MenuPanel | Role::Popover | Role::Tooltip | Role::SectionBody
) {
continue;
}
if want(role)
&& let Ok(text) = self.texts.get(*child)
&& !text.0.is_empty()
{
if !out.is_empty() {
out.push(' ');
}
out.push_str(&text.0);
}
self.collect(*child, want, out);
}
}
fn control_text(&self, root: Entity) -> Option<String> {
self.words(root, |role| role == Role::ControlText)
}
fn name_of(&self, root: Entity) -> Option<String> {
self.words(root, names).or_else(|| {
self.tooltips
.get(root)
.ok()
.map(|tip| tip.text.clone())
.filter(|text| !text.is_empty())
})
}
fn own(&self, entity: Entity) -> Option<String> {
self.texts
.get(entity)
.ok()
.map(|text| text.0.clone())
.filter(|text| !text.is_empty())
}
fn selected_option(&self, root: Entity, index: usize) -> Option<String> {
let mut options = Vec::new();
self.options(root, &mut options);
options.get(index).and_then(|option| self.own(*option))
}
fn options(&self, from: Entity, out: &mut Vec<Entity>) {
for child in self.children.get(from).into_iter().flat_map(|c| c.iter()) {
if self.roles.get(child) == Ok(&Role::ChoiceOption) {
out.push(child);
}
self.options(child, out);
}
}
fn parent(&self, entity: Entity) -> Option<Entity> {
self.parents.get(entity).ok().map(ChildOf::parent)
}
}
fn describe(entity: Entity, role: Role, facts: &FactsItem, look: &Look) -> Option<Wanted> {
let mut wanted = Wanted::default();
match role {
Role::Heading => {
wanted.role = Some(Ak::Heading);
wanted.name = look.own(entity);
}
Role::Label
| Role::PromptKey
| Role::PromptButton
| Role::MenuNote
| Role::PaletteCount
| Role::SectionNote => {
wanted.role = Some(Ak::Label);
wanted.value = look.own(entity);
}
Role::Button => {
wanted.name = look.name_of(entity);
if facts.header
&& let Some(parent) = look.parent(entity)
&& let Ok(open) = look.sections.get(parent)
{
wanted.expanded = Some(open.0);
}
}
Role::Meter => {
let meter = facts.meter?;
wanted.role = Some(Ak::Meter);
wanted.numeric = Some((f64::from(meter.value), 0.0, f64::from(meter.max)));
}
Role::Tooltip => wanted.role = Some(Ak::Tooltip),
Role::Scrim => {
wanted.role = Some(Ak::Dialog);
wanted.modal = true;
wanted.name = look.words(entity, |role| role == Role::Heading);
}
Role::Toggle => wanted.name = look.control_text(entity),
Role::Slider => {
wanted.name = look.control_text(entity).or_else(|| {
let parent = look.parent(entity)?;
(look.roles.get(parent) == Ok(&Role::ColorStripBox))
.then(|| look.words(parent, |role| role == Role::Label))
.flatten()
});
wanted.value = look.words(entity, |role| role == Role::SliderReadout);
}
Role::Choice => {
wanted.role = Some(Ak::SpinButton);
wanted.name = look.control_text(entity);
wanted.value = facts
.choice
.and_then(|value| look.selected_option(entity, value.0));
}
Role::Radial => {
wanted.role = Some(Ak::SpinButton);
wanted.value = look.words(entity, |role| role == Role::RadialCentre);
}
Role::Slot => {
wanted.name = look.words(entity, |role| role == Role::SlotName);
wanted.value = look.words(entity, |role| role == Role::SlotCount);
}
Role::SlotEmpty => wanted.name = Some(EMPTY_SLOT.to_owned()),
Role::Binding => {
wanted.name = look.control_text(entity);
wanted.value = if facts.capturing {
look.words(entity, |role| role == Role::BindingPrompt)
} else {
look.words(entity, |role| role == Role::BindingText)
};
}
Role::Toast => {
let alert = facts
.toast
.is_some_and(|toast| toast.kind() == ToastKind::Alert);
wanted.role = Some(if alert { Ak::Alert } else { Ak::Status });
wanted.name = look.words(entity, |role| role == Role::ToastText);
}
Role::ToastAction => wanted.name = look.words(entity, names),
Role::Table => {
let rows = look.tables.get(entity).ok().map(|(table, _)| table.len());
if look.trees.contains(entity) {
wanted.role = Some(Ak::Tree);
wanted.size_of_set = rows;
} else {
wanted.role = Some(Ak::Table);
wanted.row_count = rows;
wanted.column_count = look
.tables
.get(entity)
.ok()
.map(|(table, _)| table.columns().len());
}
}
Role::TableRow => {
let tree = facts.row.is_some_and(|row| look.trees.contains(row.table));
wanted.role = Some(if tree { Ak::TreeItem } else { Ak::Row });
wanted.selected = Some(facts.selected);
if let Some(row) = facts.row {
if tree {
wanted.position_in_set = Some(row.index);
tree_place(&mut wanted, row, look);
} else {
wanted.row_index = Some(row.index);
}
}
}
Role::TableCell => {
wanted.role = Some(Ak::Cell);
wanted.name = look.words(entity, names);
}
Role::TableHeaderCell | Role::Tab => wanted.name = look.words(entity, names),
Role::TableEdge => {
wanted.role = Some(Ak::Splitter);
wanted.name = Some(COLUMN_EDGE.to_owned());
}
Role::TabPage => wanted.role = Some(Ak::TabPanel),
Role::TextField => {
if facts.area {
wanted.role = Some(Ak::MultilineTextInput);
}
let row = facts.field?.0;
wanted.name = look.control_text(row).or_else(|| {
(look.roles.get(row) == Ok(&Role::PaletteField)).then(|| COMMAND_FIELD.to_owned())
});
wanted.value = look.values.get(row).ok().map(|value| value.0.clone());
}
Role::ColorSwatch => {
let row = facts.swatch?.0;
wanted.role = Some(Ak::ColorWell);
wanted.name = look.control_text(row);
wanted.value = look.values.get(row).ok().map(|value| value.0.clone());
}
Role::ColorArea => {
wanted.role = Some(Ak::Group);
wanted.name = Some(COLOUR_AREA.to_owned());
}
Role::Viewport => wanted.role = Some(Ak::Pane),
Role::SplitDivider => {
facts.divider?;
wanted.role = Some(Ak::Splitter);
wanted.name = Some(DIVIDER.to_owned());
let fraction = look
.parent(entity)
.and_then(|parent| look.fractions.get(parent).ok());
wanted.numeric = fraction.map(|fraction| (f64::from(fraction.0), 0.0, 1.0));
}
Role::MenuBar => wanted.role = Some(Ak::MenuBar),
Role::MenuPanel => wanted.role = Some(Ak::Menu),
Role::MenuTitle => {
wanted.role = Some(Ak::MenuItem);
wanted.name = look.words(entity, names);
wanted.popup = true;
wanted.expanded = Some(facts.open);
}
Role::MenuItem => {
let (role, popup) = facts.item.map_or((Ak::MenuItem, false), MenuItem::access);
wanted.role = Some(role);
wanted.name = look.words(entity, names);
wanted.popup = popup;
if popup {
wanted.expanded = Some(facts.open);
}
if matches!(role, Ak::MenuItemCheckBox | Ak::MenuItemRadio) {
wanted.toggled = Some(facts.checked);
}
}
Role::PaletteRow => {
wanted.role = Some(Ak::ListBoxOption);
wanted.name = look.words(entity, names);
wanted.selected = Some(facts.selected);
}
Role::Progress => {
let progress = facts.progress?;
wanted.role = Some(Ak::ProgressIndicator);
wanted.numeric = Some((f64::from(progress.0), 0.0, 1.0));
wanted.value = look.words(entity, |role| role == Role::ProgressLine);
}
Role::Spinner => {
facts.label?;
wanted.role = Some(Ak::ProgressIndicator);
}
Role::Toolbar => wanted.role = Some(Ak::Toolbar),
Role::ToggleGroup => wanted.role = Some(Ak::RadioGroup),
Role::GroupOption => {
wanted.role = Some(Ak::RadioButton);
wanted.name = look.name_of(entity);
wanted.toggled = Some(facts.checked);
}
_ => {}
}
if let Some(label) = facts.label {
wanted.name = Some(label.0.clone());
}
if let Some(cooldown) = facts.cooldown
&& !cooldown.is_ready()
&& let Some(name) = wanted.name.as_mut()
{
name.push_str(", ");
name.push_str(&cooldown_words(cooldown.remaining));
}
wanted.disabled = facts.disabled;
wanted.actions = if wanted.tree_branch {
TREE_BRANCH
} else {
actions_of(&facts.handles)
};
wanted.hidden = facts.seen.is_some_and(|seen| !seen.get())
|| facts.node.is_some_and(|node| node.display == Display::None);
Some(wanted)
}
fn tree_place(wanted: &mut Wanted, row: &TableRow, look: &Look) {
let Ok((table, disabled)) = look.tables.get(row.table) else {
return;
};
let Some(outline) = table
.source::<Tree>()
.and_then(|tree| tree.outline_of(row.key))
else {
return;
};
wanted.level = Some(outline.depth);
match outline.open {
Some(open) => {
wanted.expanded = Some(open);
if !disabled {
wanted.tree_branch = true;
}
}
None => wanted.end_node = true,
}
}
fn says(node: &Node_, wanted: &Wanted) -> bool {
wanted.role.is_none_or(|role| node.role() == role)
&& node.label() == wanted.name.as_deref()
&& node.value() == wanted.value.as_deref()
&& wanted.numeric.is_none_or(|(value, min, max)| {
node.numeric_value() == Some(value)
&& node.min_numeric_value() == Some(min)
&& node.max_numeric_value() == Some(max)
})
&& wanted
.toggled
.is_none_or(|on| node.toggled() == Some(Toggled::from(on)))
&& wanted
.selected
.is_none_or(|selected| node.is_selected() == Some(selected))
&& wanted
.expanded
.is_none_or(|expanded| node.is_expanded() == Some(expanded))
&& (!wanted.end_node || node.is_expanded().is_none())
&& wanted.level.is_none_or(|level| node.level() == Some(level))
&& wanted
.position_in_set
.is_none_or(|at| node.position_in_set() == Some(at))
&& wanted
.size_of_set
.is_none_or(|size| node.size_of_set() == Some(size))
&& wanted
.row_index
.is_none_or(|at| node.row_index() == Some(at))
&& wanted
.row_count
.is_none_or(|count| node.row_count() == Some(count))
&& wanted
.column_count
.is_none_or(|count| node.column_count() == Some(count))
&& (!wanted.popup || node.has_popup() == Some(HasPopup::Menu))
&& node.is_modal() == wanted.modal
&& node.is_hidden() == wanted.hidden
&& node.is_disabled() == wanted.disabled
&& MANAGED
.iter()
.all(|action| node.supports_action(*action) == wanted.actions.contains(action))
}
fn write(node: &mut Node_, wanted: &Wanted) {
if let Some(role) = wanted.role {
node.set_role(role);
}
match &wanted.name {
Some(name) => node.set_label(name.as_str()),
None => node.clear_label(),
}
match &wanted.value {
Some(value) => node.set_value(value.as_str()),
None => node.clear_value(),
}
if let Some((value, min, max)) = wanted.numeric {
node.set_numeric_value(value);
node.set_min_numeric_value(min);
node.set_max_numeric_value(max);
}
if let Some(on) = wanted.toggled {
node.set_toggled(Toggled::from(on));
}
if let Some(selected) = wanted.selected {
node.set_selected(selected);
}
if let Some(expanded) = wanted.expanded {
node.set_expanded(expanded);
}
if wanted.end_node {
node.clear_expanded();
}
if let Some(level) = wanted.level {
node.set_level(level);
}
if let Some(at) = wanted.position_in_set {
node.set_position_in_set(at);
}
if let Some(size) = wanted.size_of_set {
node.set_size_of_set(size);
}
if let Some(at) = wanted.row_index {
node.set_row_index(at);
}
if let Some(count) = wanted.row_count {
node.set_row_count(count);
}
if let Some(count) = wanted.column_count {
node.set_column_count(count);
}
if wanted.popup {
node.set_has_popup(HasPopup::Menu);
}
flag(wanted.modal, node, Node_::set_modal, Node_::clear_modal);
flag(wanted.hidden, node, Node_::set_hidden, Node_::clear_hidden);
flag(
wanted.disabled,
node,
Node_::set_disabled,
Node_::clear_disabled,
);
for action in MANAGED {
if wanted.actions.contains(&action) {
node.add_action(action);
} else {
node.remove_action(action);
}
}
}
fn flag(on: bool, node: &mut Node_, set: fn(&mut Node_), clear: fn(&mut Node_)) {
if on {
set(node);
} else {
clear(node);
}
}
pub(crate) fn update_accessibility(
requested: Option<Res<AccessibilityRequested>>,
mut commands: Commands,
mut nodes: Query<(Entity, &Role, Option<&mut AccessibilityNode>, Facts)>,
look: Look,
) {
if requested.is_some_and(|requested| !requested.get()) {
return;
}
for (entity, &role, node, facts) in &mut nodes {
let kind = spec(role);
let wanted = match kind {
Spec::Hidden => None,
Spec::Group => {
let mut group = Wanted {
role: Some(Ak::GenericContainer),
..default()
};
group.name = facts.label.map(|label| label.0.clone());
group.hidden = facts.seen.is_some_and(|seen| !seen.get())
|| facts.node.is_some_and(|node| node.display == Display::None);
Some(group)
}
Spec::Node => describe(entity, role, &facts, &look),
};
match (node, wanted) {
(Some(mut node), Some(wanted)) => {
if !says(&node, &wanted) {
write(&mut node, &wanted);
}
}
(Some(mut node), None) => {
if !node.is_hidden() {
node.set_hidden();
}
}
(None, Some(wanted)) => {
let mut node = Node_::new(wanted.role.unwrap_or(Ak::GenericContainer));
write(&mut node, &wanted);
commands
.entity(entity)
.insert(AccessibilityNode::from(node));
commands.queue(move |world: &mut World| {
if let Some(mut computed) = world.get_mut::<ComputedNode>(entity) {
computed.set_changed();
}
});
}
(None, None) => {}
}
}
}