use crate::core::ObjectId;
use crate::platform::accessibility::{A11yState, AccessibilityBridge};
struct InstalledBridge {
ptr: *const dyn AccessibilityBridge,
}
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static INSTALLED: core::cell::RefCell<Option<InstalledBridge>> =
const { core::cell::RefCell::new(None) };
#[allow(clippy::missing_const_for_thread_local)]
static PLATFORM_RESOLVED: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
}
pub fn install_bridge(bridge: &'static dyn AccessibilityBridge) -> bool {
let _ = PLATFORM_RESOLVED.try_with(|flag| flag.set(true));
INSTALLED
.try_with(|slot| slot.borrow_mut().replace(InstalledBridge { ptr: bridge }).is_some())
.unwrap_or(false)
}
pub fn uninstall_bridge() -> bool {
INSTALLED.try_with(|slot| slot.borrow_mut().take().is_some()).unwrap_or(false)
}
fn with_bridge<R>(f: impl FnOnce(&dyn AccessibilityBridge) -> R) -> Option<R> {
let installed = INSTALLED
.try_with(|slot| slot.borrow().as_ref().map(|installed| installed.ptr))
.ok()
.flatten();
let ptr = match installed {
Some(ptr) => ptr,
None => platform_bridge_ptr()?,
};
Some(f(unsafe { &*ptr }))
}
fn platform_bridge_ptr() -> Option<*const dyn AccessibilityBridge> {
let already = PLATFORM_RESOLVED.try_with(|flag| flag.get()).unwrap_or(false);
if !already {
let _ = PLATFORM_RESOLVED.try_with(|flag| flag.set(true));
}
let bridge = crate::platform::runtime::get_platform().accessibility_bridge()?;
Some(bridge as *const dyn AccessibilityBridge)
}
pub fn submit_mounted(id: ObjectId, state: &A11yState) {
with_bridge(|bridge| {
bridge.submit_node_state(id, state);
bridge.notify_state_changed(id);
});
}
pub fn submit_unmounted(id: ObjectId) {
with_bridge(|bridge| {
bridge.unregister_node(id);
bridge.notify_state_changed(id);
});
}
pub fn submit_geometry_changed(id: ObjectId) {
with_bridge(|bridge| bridge.notify_state_changed(id));
}
pub fn submit_state_changed(id: ObjectId) {
with_bridge(|bridge| bridge.notify_state_changed(id));
}
pub fn submit_semantic_changed(id: ObjectId) {
with_bridge(|bridge| bridge.notify_state_changed(id));
}
pub fn submit_value_changed(id: ObjectId) {
with_bridge(|bridge| bridge.notify_value_changed(id));
}
pub fn submit_focus_changed(id: ObjectId) {
with_bridge(|bridge| bridge.notify_focus_changed(id));
}
#[cfg(all(test, not(alloc_frugal)))]
mod tests {
use super::*;
use crate::platform::accessibility::{A11yRole, A11yState};
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq)]
enum Call {
Name(ObjectId, String),
State(ObjectId),
Value(ObjectId),
Focus(ObjectId),
Node(ObjectId, A11yState),
Unregister(ObjectId),
}
struct Recording {
calls: Mutex<Vec<Call>>,
}
impl Recording {
fn new() -> Self {
Self { calls: Mutex::new(Vec::new()) }
}
fn calls(&self) -> Vec<Call> {
self.calls.lock().expect("recording mutex is never poisoned").clone()
}
}
impl AccessibilityBridge for Recording {
fn set_accessibility_name(&self, id: ObjectId, name: &str) {
self.calls.lock().expect("not poisoned").push(Call::Name(id, name.to_string()));
}
fn accessibility_name(&self, _id: ObjectId) -> Option<String> {
None
}
fn notify_name_changed(&self, _id: ObjectId) {}
fn notify_value_changed(&self, id: ObjectId) {
self.calls.lock().expect("not poisoned").push(Call::Value(id));
}
fn notify_state_changed(&self, id: ObjectId) {
self.calls.lock().expect("not poisoned").push(Call::State(id));
}
fn notify_focus_changed(&self, id: ObjectId) {
self.calls.lock().expect("not poisoned").push(Call::Focus(id));
}
fn submit_node_state(&self, id: ObjectId, state: &A11yState) {
self.calls.lock().expect("not poisoned").push(Call::Node(id, state.clone()));
}
fn unregister_node(&self, id: ObjectId) {
self.calls.lock().expect("not poisoned").push(Call::Unregister(id));
}
}
fn with_recording<R>(f: impl FnOnce(&Recording) -> R) -> R {
let recorder: &'static Recording = Box::leak(Box::new(Recording::new()));
let had = install_bridge(recorder);
let result = f(recorder);
let removed = uninstall_bridge();
assert!(!had, "no bridge should have been installed before this test");
assert!(removed, "the substitute must be removable, or it leaks into the next test");
result
}
#[test]
fn mounting_submits_a_node() {
with_recording(|recorder| {
let state = A11yState {
role: A11yRole::Button,
label: "Save".to_string(),
..A11yState::default()
};
submit_mounted(7, &state);
let calls = recorder.calls();
assert!(
calls.contains(&Call::Node(7, state.clone())),
"the node's complete state must reach the bridge: {calls:?}"
);
assert!(
calls.contains(&Call::State(7)),
"and its creation must be reported: {calls:?}"
);
});
}
#[test]
fn mounting_submits_every_derived_field() {
with_recording(|recorder| {
let state = A11yState {
role: A11yRole::CheckBox,
label: "Subscribe".to_string(),
description: "Toggles the newsletter".to_string(),
enabled: false,
focused: true,
selected: true,
expanded: true,
value: "on".to_string(),
checked: Some(true),
mixed: true,
children: vec![11, 12, 13],
};
submit_mounted(21, &state);
let submitted = recorder
.calls()
.into_iter()
.find_map(|call| match call {
Call::Node(21, recorded) => Some(recorded),
_ => None,
})
.expect("the full node state must have been submitted");
assert_eq!(submitted.role, A11yRole::CheckBox, "role crossed the boundary");
assert_eq!(submitted.label, "Subscribe", "label crossed the boundary");
assert_eq!(
submitted.description, "Toggles the newsletter",
"description crossed the boundary"
);
assert!(!submitted.enabled, "enabled crossed the boundary");
assert!(submitted.focused, "focused crossed the boundary");
assert!(submitted.selected, "selected crossed the boundary");
assert!(submitted.expanded, "expanded crossed the boundary");
assert_eq!(submitted.value, "on", "value crossed the boundary");
assert_eq!(submitted.checked, Some(true), "checked crossed the boundary");
assert!(submitted.mixed, "mixed crossed the boundary");
assert_eq!(submitted.children, vec![11, 12, 13], "children crossed the boundary");
});
}
#[test]
fn unmounting_submits_the_removal() {
with_recording(|recorder| {
submit_unmounted(9);
assert_eq!(recorder.calls(), vec![Call::Unregister(9), Call::State(9)]);
});
}
#[test]
fn every_submit_point_reaches_the_bridge() {
with_recording(|recorder| {
submit_geometry_changed(1);
submit_state_changed(2);
submit_semantic_changed(3);
submit_value_changed(4);
submit_focus_changed(5);
assert_eq!(
recorder.calls(),
vec![
Call::State(1),
Call::State(2),
Call::State(3),
Call::Value(4),
Call::Focus(5),
]
);
});
}
#[test]
fn the_submit_pump_round_trips_full_state_and_removes_on_unmount() {
use std::collections::HashMap as StdHashMap;
use std::sync::Mutex as StdMutex;
struct Storing {
nodes: StdMutex<StdHashMap<ObjectId, A11yState>>,
}
impl AccessibilityBridge for Storing {
fn set_accessibility_name(&self, _id: ObjectId, _name: &str) {}
fn accessibility_name(&self, _id: ObjectId) -> Option<String> {
None
}
fn notify_name_changed(&self, _id: ObjectId) {}
fn notify_value_changed(&self, _id: ObjectId) {}
fn notify_state_changed(&self, _id: ObjectId) {}
fn notify_focus_changed(&self, _id: ObjectId) {}
fn submit_node_state(&self, id: ObjectId, state: &A11yState) {
self.nodes.lock().expect("not poisoned").insert(id, state.clone());
}
fn node_state(&self, id: ObjectId) -> Option<A11yState> {
self.nodes.lock().expect("not poisoned").get(&id).cloned()
}
fn unregister_node(&self, id: ObjectId) {
self.nodes.lock().expect("not poisoned").remove(&id);
}
fn node_count(&self) -> usize {
self.nodes.lock().expect("not poisoned").len()
}
}
let bridge: &'static Storing =
Box::leak(Box::new(Storing { nodes: StdMutex::new(StdHashMap::new()) }));
let had = install_bridge(bridge);
assert!(!had, "no bridge should have been installed before this test");
let baseline = bridge.node_count();
assert_eq!(baseline, 0, "a fresh store is empty");
for cycle in 0..3u64 {
let id = 100 + cycle;
let state = A11yState {
role: A11yRole::Slider,
label: String::new(),
value: "42".to_string(),
..A11yState::default()
};
submit_mounted(id, &state);
let stored = bridge.node_state(id).expect("the node must be readable after mount");
assert_eq!(stored.role, A11yRole::Slider, "role survived cycle {cycle}");
assert_eq!(stored.value, "42", "value survived cycle {cycle}");
assert_eq!(bridge.node_count(), baseline + 1, "one live node in cycle {cycle}");
submit_unmounted(id);
assert!(bridge.node_state(id).is_none(), "the node is gone after unmount");
assert_eq!(
bridge.node_count(),
baseline,
"the store returns to baseline after cycle {cycle}"
);
}
let removed = uninstall_bridge();
assert!(removed, "the substitute must be removable, or it leaks into the next test");
}
#[test]
fn without_a_bridge_every_submit_point_is_a_no_op() {
let _ = uninstall_bridge();
let state = A11yState { role: A11yRole::Label, ..A11yState::default() };
submit_mounted(11, &state);
submit_unmounted(11);
submit_geometry_changed(11);
submit_state_changed(11);
submit_semantic_changed(11);
submit_value_changed(11);
submit_focus_changed(11);
}
}