use std::{
cell::{Cell, RefCell},
collections::HashMap,
fmt::Write,
rc::Rc,
};
use cranpose_app_shell::AppShell;
use cranpose_render_wgpu::WgpuRenderer;
use cranpose_ui::LiveRegionMode;
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
use web_sys::{Document, Element, HtmlCanvasElement, HtmlElement, MouseEvent};
use web_time::Instant;
use crate::{
accessibility::{self, AccessibilityElement, AccessibilityRect, Replaced, edits_text},
accessibility_publish_policy::AccessibilityPublishPolicy,
web_accessibility_attributes::{
MirrorAttributes, PageTarget, mirror_tag, mirror_text, page_targets,
},
web_accessibility_order::ChildMoves,
web_text_input::{
WebTextInput, apply_editor_text, attach_field_key_listener, attach_field_listeners,
attach_selection_listener, focus_field,
},
};
const MIRROR_CLASS: &str = "cranpose-mirror";
#[derive(Default)]
pub(crate) struct MirrorLinks {
node_ids: RefCell<HashMap<i32, cranpose_core::NodeId>>,
read: Cell<bool>,
pub(crate) edited: Cell<bool>,
}
fn attach_page_listener(
root: &HtmlElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
links: Rc<MirrorLinks>,
) -> Result<(), JsValue> {
let key_down = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
let Some(target) = key_target(&event) else {
return;
};
let Some(node_id) = node_id_attribute(&target, "data-cranpose-page", &links) else {
return;
};
match event.key().as_str() {
"PageDown" => page_mirror(&event, &target, &app, &links, node_id, 1.0),
"PageUp" => page_mirror(&event, &target, &app, &links, node_id, -1.0),
"Home" => jump_mirror(&event, &target, &app, &links, node_id, false),
"End" => jump_mirror(&event, &target, &app, &links, node_id, true),
_ => {}
}
}) as Box<dyn FnMut(_)>);
root.add_event_listener_with_callback("keydown", key_down.as_ref().unchecked_ref())?;
key_down.forget();
Ok(())
}
fn page_mirror(
event: &web_sys::KeyboardEvent,
target: &Element,
app: &Rc<RefCell<AppShell<WgpuRenderer>>>,
links: &MirrorLinks,
node_id: cranpose_core::NodeId,
sign: f32,
) {
let (Some(dx), Some(dy)) = (
number_attribute(target, "data-cranpose-page-dx"),
number_attribute(target, "data-cranpose-page-dy"),
) else {
return;
};
event.prevent_default();
on_live_tree(app, links, |root| {
accessibility::scroll_by(root, node_id, sign * dx, sign * dy)
});
}
fn jump_mirror(
event: &web_sys::KeyboardEvent,
target: &Element,
app: &Rc<RefCell<AppShell<WgpuRenderer>>>,
links: &MirrorLinks,
node_id: cranpose_core::NodeId,
last: bool,
) {
let Some(last_row) = number_attribute(target, "data-cranpose-last-row") else {
return;
};
let index = if last { last_row.max(0.0) as usize } else { 0 };
event.prevent_default();
on_live_tree(app, links, |root| {
accessibility::scroll_to_index(root, node_id, index)
});
}
fn key_target(event: &web_sys::KeyboardEvent) -> Option<Element> {
event
.target()
.and_then(|target| target.dyn_into::<Element>().ok())
}
fn number_attribute(target: &Element, name: &str) -> Option<f32> {
target
.get_attribute(name)
.and_then(|value| value.parse::<f32>().ok())
}
pub(crate) fn node_id_attribute(
target: &Element,
name: &str,
links: &MirrorLinks,
) -> Option<cranpose_core::NodeId> {
target
.get_attribute(name)
.and_then(|value| value.parse::<i32>().ok())
.and_then(|element_id| links.node_ids.borrow().get(&element_id).copied())
}
pub(crate) fn on_live_tree(
app: &Rc<RefCell<AppShell<WgpuRenderer>>>,
links: &MirrorLinks,
act: impl FnOnce(&cranpose_ui::SemanticsNode) -> bool,
) {
if let Ok(mut shell) = app.try_borrow_mut() {
links.read.set(true);
accessibility::run_reader_action(&mut shell, act);
}
}
fn apply_field_text(node: &HtmlElement, element: &AccessibilityElement) -> Result<(), JsValue> {
let (Some(value), Some((anchor, focus))) = (&element.value, element.details().text_selection)
else {
return Ok(());
};
apply_editor_text(node, value, anchor, focus)
}
fn mirror_element(document: &Document, tag: &str) -> Result<HtmlElement, JsValue> {
let node = document.create_element(tag)?.dyn_into::<HtmlElement>()?;
node.set_class_name(MIRROR_CLASS);
Ok(node)
}
fn mirror_node(
document: &Document,
id: i32,
element: &AccessibilityElement,
page: Option<PageTarget>,
attributes: &mut MirrorAttributes,
) -> Result<HtmlElement, JsValue> {
let node = mirror_element(document, mirror_tag(element))?;
attributes.describe(id, element, page);
for (name, value) in attributes.iter() {
node.set_attribute(name, value)?;
}
if let Some(text) = mirror_text(element).filter(|text| !text.is_empty()) {
node.set_text_content(Some(text));
}
apply_field_text(&node, element)?;
Ok(node)
}
fn write_text(node: &HtmlElement, text: Option<&str>) {
match (node.first_child(), text) {
(Some(child), Some(text)) => child.set_node_value(Some(text)),
(_, text) => node.set_text_content(text),
}
}
fn mirror_style(document: &Document) -> Result<Element, JsValue> {
let style = document.create_element("style")?;
style.set_text_content(Some(&format!(
".{MIRROR_CLASS}{{position:fixed!important;opacity:0!important;\
pointer-events:none!important;overflow:hidden!important}}"
)));
Ok(style)
}
pub(crate) struct Placement {
left: f64,
top: f64,
scale_x: f64,
scale_y: f64,
}
impl Placement {
pub(crate) fn of(canvas: &HtmlCanvasElement, viewport: (f32, f32)) -> Self {
let rect = canvas.get_bounding_client_rect();
Self {
left: rect.left(),
top: rect.top(),
scale_x: rect.width() / viewport.0.max(1.0) as f64,
scale_y: rect.height() / viewport.1.max(1.0) as f64,
}
}
pub(crate) fn rect(&self, bounds: AccessibilityRect) -> [f64; 4] {
[
self.left + f64::from(bounds.x) * self.scale_x,
self.top + f64::from(bounds.y) * self.scale_y,
f64::from(bounds.width) * self.scale_x,
f64::from(bounds.height) * self.scale_y,
]
}
}
fn attach_focus_listener(
root: &HtmlElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
links: Rc<MirrorLinks>,
) -> Result<(), JsValue> {
let focus_in = Closure::wrap(Box::new(move |event: web_sys::Event| {
let Some(target) = event.target().and_then(|t| t.dyn_into::<Element>().ok()) else {
return;
};
let Some(element_id) = target
.get_attribute("data-cranpose-node")
.and_then(|value| value.parse::<i32>().ok())
else {
return;
};
let Some(node_id) = links.node_ids.borrow().get(&element_id).copied() else {
return;
};
on_live_tree(&app, &links, |root| {
accessibility::focus_node(root, node_id)
});
}) as Box<dyn FnMut(_)>);
root.add_event_listener_with_callback("focusin", focus_in.as_ref().unchecked_ref())?;
focus_in.forget();
Ok(())
}
fn mirror_root(document: &Document) -> Result<HtmlElement, JsValue> {
let root = document.create_element("div")?.dyn_into::<HtmlElement>()?;
root.set_attribute("data-cranpose-accessibility", "")?;
root.set_attribute("aria-label", "Application controls")?;
let style = root.style();
style.set_property("position", "fixed")?;
style.set_property("inset", "0")?;
style.set_property("z-index", "2147483647")?;
style.set_property("pointer-events", "none")?;
Ok(root)
}
fn on_mirror_click(
root: &HtmlElement,
mut action: impl FnMut(Element) + 'static,
) -> Result<(), JsValue> {
let click = Closure::wrap(Box::new(move |event: MouseEvent| {
if let Some(target) = event
.target()
.and_then(|target| target.dyn_into::<Element>().ok())
{
action(target);
}
}) as Box<dyn FnMut(_)>);
root.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())?;
click.forget();
Ok(())
}
fn attach_click_listener(
root: &HtmlElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
links: Rc<MirrorLinks>,
) -> Result<(), JsValue> {
on_mirror_click(root, move |target| {
if !target.has_attribute("data-cranpose-clickable") {
return;
}
let Some(node_id) = node_id_attribute(&target, "data-cranpose-node", &links) else {
return;
};
let canvas_key = target
.get_attribute("data-cranpose-canvas")
.and_then(|value| value.parse::<u64>().ok());
if let Ok(mut shell) = app.try_borrow_mut() {
links.read.set(true);
shell.accessibility_activate(node_id, canvas_key);
}
})
}
fn attach_action_listener(
root: &HtmlElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
links: Rc<MirrorLinks>,
) -> Result<(), JsValue> {
on_mirror_click(root, move |target| {
let Some(index) = target
.get_attribute("data-cranpose-action")
.and_then(|value| value.parse::<usize>().ok())
else {
return;
};
let Some(named) = target
.get_attribute("data-cranpose-action-named")
.and_then(|value| value.parse::<usize>().ok())
else {
return;
};
let Some(node_id) = node_id_attribute(&target, "data-cranpose-action-node", &links) else {
return;
};
let canvas_key = target
.get_attribute("data-cranpose-canvas")
.and_then(|value| value.parse::<u64>().ok());
on_live_tree(&app, &links, |root| {
accessibility::perform_listed_action(root, node_id, canvas_key, named, index)
});
})
}
fn attach_key_listener(
root: &HtmlElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
links: Rc<MirrorLinks>,
) -> Result<(), JsValue> {
let key_down = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
let Some(target) = key_target(&event) else {
return;
};
let read = |name: &str| number_attribute(&target, name);
let (Some(current), Some(min), Some(max), Some(step)) = (
read("data-cranpose-value"),
read("data-cranpose-min"),
read("data-cranpose-max"),
read("data-cranpose-step"),
) else {
return;
};
let next = match event.key().as_str() {
"ArrowRight" | "ArrowUp" => current + step,
"ArrowLeft" | "ArrowDown" => current - step,
"Home" => min,
"End" => max,
_ => return,
};
let Some(node_id) = node_id_attribute(&target, "data-cranpose-node", &links) else {
return;
};
event.prevent_default();
event.stop_propagation();
on_live_tree(&app, &links, |root| {
accessibility::set_progress(root, node_id, next.clamp(min, max))
});
}) as Box<dyn FnMut(_)>);
root.add_event_listener_with_callback("keydown", key_down.as_ref().unchecked_ref())?;
key_down.forget();
Ok(())
}
fn live_region(document: &Document, politeness: &str) -> Result<HtmlElement, JsValue> {
let region = document.create_element("div")?.dyn_into::<HtmlElement>()?;
region.set_attribute("aria-live", politeness)?;
region.set_attribute("aria-atomic", "true")?;
region.set_attribute("data-cranpose-live", politeness)?;
let style = region.style();
style.set_property("position", "fixed")?;
style.set_property("width", "1px")?;
style.set_property("height", "1px")?;
style.set_property("overflow", "hidden")?;
style.set_property("clip", "rect(0 0 0 0)")?;
style.set_property("white-space", "nowrap")?;
style.set_property("pointer-events", "none")?;
Ok(region)
}
#[derive(Default)]
struct AttributeRoom {
shown: MirrorAttributes,
now: MirrorAttributes,
}
struct MirrorEntry {
node: HtmlElement,
actions: Vec<HtmlElement>,
page: Option<PageTarget>,
placed: Option<[f64; 4]>,
parent: Option<i32>,
written_at: Option<usize>,
replaced: bool,
written_actions: usize,
}
impl MirrorEntry {
fn new(
document: &Document,
id: i32,
element: &AccessibilityElement,
page: Option<PageTarget>,
attributes: &mut MirrorAttributes,
) -> Result<Self, JsValue> {
let mut entry = Self {
node: mirror_node(document, id, element, page, attributes)?,
actions: Vec::new(),
page,
placed: None,
parent: None,
written_at: None,
replaced: false,
written_actions: 0,
};
entry.update_actions(document, id, element)?;
Ok(entry)
}
fn detach(&mut self) {
self.node.remove();
for button in &self.actions {
button.remove();
}
self.written_at = None;
}
fn push_children(&mut self, id: i32, children: &mut Vec<MirrorChild>, written: bool) {
let was = self.written_at.filter(|_| written);
self.written_at = Some(children.len());
children.push(MirrorChild {
id,
slot: 0,
was: was.filter(|_| !self.replaced),
});
children.extend((0..self.actions.len()).map(|button| {
MirrorChild {
id,
slot: button + 1,
was: was
.filter(|_| button < self.written_actions)
.map(|at| at + 1 + button),
}
}));
self.replaced = false;
self.written_actions = self.actions.len();
}
fn child(&self, slot: usize) -> Option<&HtmlElement> {
match slot.checked_sub(1) {
None => Some(&self.node),
Some(button) => self.actions.get(button),
}
}
fn update(
&mut self,
document: &Document,
id: i32,
shown: Option<&AccessibilityElement>,
element: &AccessibilityElement,
page: Option<PageTarget>,
attributes: &mut AttributeRoom,
) -> Result<(), JsValue> {
match shown.filter(|shown| mirror_tag(shown) == mirror_tag(element)) {
Some(shown) => {
attributes.shown.describe(id, shown, self.page);
attributes.now.describe(id, element, page);
for name in attributes.now.dropped_from(&attributes.shown) {
self.node.remove_attribute(name)?;
}
for (name, value) in attributes.now.written_over(&attributes.shown) {
self.node.set_attribute(name, value)?;
}
let text = mirror_text(element);
if mirror_text(shown) != text {
write_text(&self.node, text);
}
apply_field_text(&self.node, element)?;
}
None => {
let node = mirror_node(document, id, element, page, &mut attributes.now)?;
std::mem::replace(&mut self.node, node).remove();
self.placed = None;
self.replaced = true;
}
}
self.page = page;
self.update_actions(document, id, element)
}
fn place(&mut self, rect: [f64; 4], css: &mut String) -> Result<(), JsValue> {
if self.placed == Some(rect) {
return Ok(());
}
let [left, top, width, height] = rect;
css.clear();
write!(
css,
"left:{left}px;top:{top}px;width:{width}px;height:{height}px"
)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
for node in std::iter::once(&self.node).chain(&self.actions) {
node.style().set_css_text(css);
}
self.placed = Some(rect);
Ok(())
}
fn update_actions(
&mut self,
document: &Document,
id: i32,
element: &AccessibilityElement,
) -> Result<(), JsValue> {
let named = accessibility::reader_actions(element).len();
let actions = accessibility::listed_actions(element);
for (index, action) in actions.iter().enumerate() {
if index == self.actions.len() {
self.actions.push(mirror_element(document, "button")?);
self.placed = None;
}
let button = &self.actions[index];
let label = if element.label.is_empty() {
action.clone()
} else {
format!("{action}, {}", element.label)
};
button.set_attribute("aria-label", &label)?;
button.set_attribute("data-cranpose-action", &index.to_string())?;
button.set_attribute("data-cranpose-action-named", &named.to_string())?;
button.set_attribute("data-cranpose-action-node", &id.to_string())?;
if let Some(key) = element.canvas_key {
button.set_attribute("data-cranpose-canvas", &key.to_string())?;
} else {
button.remove_attribute("data-cranpose-canvas")?;
}
}
for button in self.actions.drain(actions.len()..) {
button.remove();
}
Ok(())
}
}
fn shown_element(shown: Option<&Replaced>, index: usize) -> Option<&AccessibilityElement> {
let shown = shown?;
shown.elements.get((*shown.was.get(index)?)?)
}
#[derive(Clone, Copy)]
struct MirrorChild {
id: i32,
slot: usize,
was: Option<usize>,
}
#[derive(Default)]
struct ChildList {
children: Vec<MirrorChild>,
replaced: bool,
}
fn reconcile_children(
parent: &HtmlElement,
list: &ChildList,
entries: &HashMap<i32, MirrorEntry>,
moves: &mut ChildMoves,
) -> Result<(), JsValue> {
moves.plan(
list.children
.iter()
.map(|child| child.was.filter(|_| !list.replaced)),
);
let node = |index: usize| {
list.children
.get(index)
.and_then(|child| entries.get(&child.id)?.child(child.slot))
.map(AsRef::<web_sys::Node>::as_ref)
.ok_or_else(|| JsValue::from_str("a mirror child has no node"))
};
for (child, before) in moves.moves() {
let before = before.map(node).transpose()?;
parent.insert_before(node(child)?, before)?;
}
Ok(())
}
#[derive(Default)]
struct MirrorOrders {
lists: HashMap<Option<i32>, ChildList>,
moves: ChildMoves,
}
impl MirrorOrders {
fn start(&mut self) {
for list in self.lists.values_mut() {
list.children.clear();
list.replaced = false;
}
}
fn push(&mut self, id: i32, entry: &mut MirrorEntry, parent: Option<i32>, written: bool) {
if entry.replaced {
self.lists.entry(Some(id)).or_default().replaced = true;
}
if entry.parent != parent {
entry.detach();
entry.parent = parent;
}
entry.push_children(
id,
&mut self.lists.entry(parent).or_default().children,
written,
);
}
fn write(
&mut self,
root: &HtmlElement,
entries: &HashMap<i32, MirrorEntry>,
) -> Result<(), JsValue> {
self.lists.retain(|_, list| !list.children.is_empty());
for (parent, list) in &self.lists {
let parent = match parent {
None => root,
Some(id) => entries
.get(id)
.map(|entry| &entry.node)
.ok_or_else(|| JsValue::from_str("a mirror parent has no node"))?,
};
reconcile_children(parent, list, entries, &mut self.moves)?;
}
Ok(())
}
}
fn enable_button(
document: &Document,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
request_frame: Rc<dyn Fn()>,
) -> Result<HtmlElement, JsValue> {
let button = document
.create_element("button")?
.dyn_into::<HtmlElement>()?;
button.set_attribute("aria-label", "Enable accessibility")?;
button.set_attribute("data-cranpose-enable-accessibility", "")?;
button.set_attribute("tabindex", "0")?;
button.style().set_css_text(
"position:fixed;left:-1px;top:-1px;width:1px;height:1px;opacity:0;\
border:0;padding:0;margin:0;overflow:hidden",
);
let click = Closure::wrap(Box::new(move |_event: MouseEvent| {
if let Ok(mut shell) = app.try_borrow_mut() {
shell.set_semantics_enabled(true);
request_frame();
}
}) as Box<dyn FnMut(_)>);
button.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())?;
click.forget();
attach_field_key_listener(&button)?;
document
.body()
.ok_or("document has no body")?
.prepend_with_node_1(&button)?;
Ok(button)
}
pub(crate) struct WebAccessibilityBridge {
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
canvas: HtmlCanvasElement,
links: Rc<MirrorLinks>,
text_input: Rc<WebTextInput>,
enable: Option<HtmlElement>,
mirror: Option<Mirror>,
}
impl WebAccessibilityBridge {
pub(crate) fn install(
document: &Document,
canvas: HtmlCanvasElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
request_frame: Rc<dyn Fn()>,
) -> Result<Self, JsValue> {
let links = Rc::new(MirrorLinks::default());
attach_selection_listener(document, Rc::clone(&app), Rc::clone(&links))?;
let text_input = WebTextInput::new(
document.clone(),
canvas.clone(),
Rc::downgrade(&app),
Rc::clone(&links),
);
let enable = if app.borrow().semantics_active() {
None
} else {
Some(enable_button(document, Rc::clone(&app), request_frame)?)
};
Ok(Self {
app,
canvas,
links,
text_input,
enable,
mirror: None,
})
}
pub(crate) fn text_input_handler(
&self,
) -> Rc<dyn cranpose_app_shell::PlatformTextInputHandler> {
self.text_input.clone()
}
pub(crate) fn sync_wake(&self) -> Option<Instant> {
self.mirror
.as_ref()
.and_then(|mirror| mirror.policy.wake_deadline())
}
pub(crate) fn sync(
&mut self,
document: &Document,
shell: &mut AppShell<WgpuRenderer>,
) -> Result<(), JsValue> {
if !shell.semantics_active() {
accessibility::drain_app_announcements();
return self.text_input.follow(shell);
}
let mirror = match self.mirror.take() {
Some(mirror) => mirror,
None => self.open(document)?,
};
let mirror = self.mirror.insert(mirror);
mirror.sync(document, shell)?;
mirror.take_reader_in(document)
}
fn open(&mut self, document: &Document) -> Result<Mirror, JsValue> {
let mirror = Mirror::open(
document,
self.canvas.clone(),
Rc::clone(&self.app),
Rc::clone(&self.links),
Rc::clone(&self.text_input),
self.enable.is_some(),
)?;
if let Some(button) = self.enable.take() {
button.remove();
}
self.text_input.hand_to_mirror();
Ok(mirror)
}
}
struct Mirror {
root: HtmlElement,
canvas: HtmlCanvasElement,
previous: accessibility::AccessibilitySnapshot,
seen_revision: Option<u64>,
seen_focus: Option<cranpose_core::NodeId>,
policy: AccessibilityPublishPolicy,
css: String,
attributes: AttributeRoom,
orders: MirrorOrders,
dirty: bool,
entries: HashMap<i32, MirrorEntry>,
links: Rc<MirrorLinks>,
text_input: Rc<WebTextInput>,
focused_element: Option<i32>,
polite: HtmlElement,
assertive: HtmlElement,
announcement_turn: bool,
reader_waits: bool,
}
impl Mirror {
fn open(
document: &Document,
canvas: HtmlCanvasElement,
app: Rc<RefCell<AppShell<WgpuRenderer>>>,
links: Rc<MirrorLinks>,
text_input: Rc<WebTextInput>,
reader_waits: bool,
) -> Result<Self, JsValue> {
let root = mirror_root(document)?;
let body = document.body().ok_or("document has no body")?;
let style = mirror_style(document)?;
body.append_child(&style)?;
body.append_child(&root)?;
let polite = live_region(document, "polite")?;
let assertive = live_region(document, "assertive")?;
body.append_child(&polite)?;
body.append_child(&assertive)?;
attach_click_listener(&root, Rc::clone(&app), Rc::clone(&links))?;
attach_focus_listener(&root, Rc::clone(&app), Rc::clone(&links))?;
attach_key_listener(&root, Rc::clone(&app), Rc::clone(&links))?;
attach_action_listener(&root, Rc::clone(&app), Rc::clone(&links))?;
attach_field_listeners(&root, Rc::clone(&app), Rc::clone(&links))?;
attach_page_listener(&root, app, Rc::clone(&links))?;
Ok(Self {
root,
canvas,
previous: accessibility::AccessibilitySnapshot::default(),
seen_revision: None,
seen_focus: None,
policy: AccessibilityPublishPolicy::new(),
css: String::new(),
attributes: AttributeRoom::default(),
orders: MirrorOrders::default(),
dirty: false,
entries: HashMap::new(),
links,
text_input,
focused_element: None,
polite,
assertive,
announcement_turn: false,
reader_waits,
})
}
fn take_reader_in(&mut self, document: &Document) -> Result<(), JsValue> {
if !self.reader_waits || self.dirty || self.previous.elements.is_empty() {
return Ok(());
}
self.reader_waits = false;
let inside = document
.active_element()
.is_some_and(|active| self.root.contains(Some(&active)));
if self.focused_element.is_some() || inside {
return Ok(());
}
if let Some(node) = self.root.query_selector("[tabindex=\"0\"]")?
&& let Ok(node) = node.dyn_into::<HtmlElement>()
{
focus_field(&node)?;
}
Ok(())
}
fn speak(&mut self, announcements: Vec<cranpose_ui::Announcement>) {
for announcement in announcements {
self.announcement_turn = !self.announcement_turn;
let text = if self.announcement_turn {
format!("{} ", announcement.text)
} else {
announcement.text
};
let region = match announcement.mode {
LiveRegionMode::Assertive => &self.assertive,
LiveRegionMode::Polite => &self.polite,
};
region.set_text_content(Some(&text));
}
}
fn follow_app_focus(
&mut self,
node: &HtmlElement,
element: &AccessibilityElement,
id: i32,
) -> Result<(), JsValue> {
if !element.focused || self.focused_element == Some(id) {
return Ok(());
}
self.focused_element = Some(id);
if edits_text(element) {
self.text_input.set_active(node);
}
focus_field(node)
}
fn sync(
&mut self,
document: &Document,
shell: &mut AppShell<WgpuRenderer>,
) -> Result<(), JsValue> {
let now = Instant::now();
if self.policy.update_enabled(shell.semantics_active()) {
self.seen_revision = None;
}
if self.links.read.take() {
self.policy.note_read(now);
}
let focus = shell.app_context().enter(cranpose_ui::active_focus_target);
let at_once =
self.seen_revision.is_none() || self.links.edited.get() || focus != self.seen_focus;
let changed = self.seen_revision != Some(shell.semantics_snapshot_revision());
let looks = self.policy.try_publish_change(now, changed, at_once);
let elements = if looks {
self.seen_focus = focus;
self.links.edited.set(false);
accessibility::snapshot_if_changed(shell, &mut self.seen_revision, &mut self.previous)
.and_then(|elements| self.previous.changed(elements, self.dirty))
} else {
None
};
let mut announcements = accessibility::drain_app_announcements();
if let Some(elements) = &elements {
announcements.extend(accessibility::live_region_announcements(
&self.previous.elements,
elements,
));
announcements.extend(accessibility::pane_title_announcements(
&self.previous.elements,
elements,
));
}
self.speak(announcements);
let Some(elements) = elements else {
return if looks {
self.sync_password(shell, &self.previous.elements)
} else {
Ok(())
};
};
let result = self.show(document, shell, elements);
self.policy.published(false);
if self.dirty {
self.seen_revision = None;
}
result
}
fn show(
&mut self,
document: &Document,
shell: &mut AppShell<WgpuRenderer>,
elements: Vec<AccessibilityElement>,
) -> Result<(), JsValue> {
let opened_dialog = accessibility::opened_dialog(&self.previous.elements, &elements);
let held = reader_focus(document).filter(|_| opened_dialog.is_none());
let app_focus_before = self.focused_element;
let placement = Placement::of(&self.canvas, shell.viewport_size());
let mut next_snapshot = std::mem::take(&mut self.previous);
let replaced = match next_snapshot.update(elements) {
Ok(replaced) => replaced,
Err(error) => {
self.previous = next_snapshot;
return Err(JsValue::from_str(&error.to_string()));
}
};
let shown = (!self.dirty).then_some(&replaced);
let mut result = (|| {
self.reconcile(document, &next_snapshot, shown, &placement)?;
self.sync_password(shell, &next_snapshot.elements)?;
for (id, element) in next_snapshot.ids.iter().zip(&next_snapshot.elements) {
let opened = opened_dialog == Some(element.node_id);
if !element.focused && !opened {
continue;
}
let Some(node) = self.entries.get(id).map(|entry| entry.node.clone()) else {
continue;
};
self.follow_app_focus(&node, element, *id)?;
if opened {
node.focus()?;
}
}
Ok(())
})();
next_snapshot.recycle(replaced.elements);
self.previous = next_snapshot;
if result.is_ok() {
result = self.settle_focus(held, app_focus_before);
}
self.dirty = result.is_err();
result
}
fn sync_password(
&self,
shell: &mut AppShell<WgpuRenderer>,
elements: &[AccessibilityElement],
) -> Result<(), JsValue> {
let mut passwords = elements
.iter()
.filter(|element| element.details().password)
.peekable();
if passwords.peek().is_none() {
return Ok(());
}
let Some(tree) = shell.semantics_tree() else {
return Ok(());
};
let fields = self.text_input.fields.borrow();
for element in passwords {
if let Some(node) = fields.get(&element.node_id)
&& let Some(field) =
accessibility::find_semantics_node(tree.root(), element.node_id)
&& let (Some(text), Some(selection)) = (&field.text, field.details().text_selection)
{
apply_editor_text(node, text, selection.start, selection.end)?;
}
}
Ok(())
}
fn reconcile(
&mut self,
document: &Document,
snapshot: &accessibility::AccessibilitySnapshot,
shown: Option<&Replaced>,
placement: &Placement,
) -> Result<(), JsValue> {
let elements = &snapshot.elements;
let ids = &snapshot.ids;
let pages = page_targets(ids, elements);
let parents: HashMap<_, _> = ids
.iter()
.zip(elements)
.filter(|(_, element)| element.canvas_key.is_none())
.map(|(id, element)| (element.node_id, *id))
.collect();
self.entries.retain(|id, entry| {
let stays = snapshot.element(*id).is_some();
if !stays {
entry.detach();
}
stays
});
self.orders.start();
self.links.node_ids.borrow_mut().clear();
self.text_input.fields.borrow_mut().clear();
for (index, ((id, element), page)) in
ids.iter().copied().zip(elements).zip(pages).enumerate()
{
let entry = match self.entries.entry(id) {
std::collections::hash_map::Entry::Occupied(entry) => {
let entry = entry.into_mut();
let shown = shown_element(shown, index);
let unchanged =
entry.page == page && shown.is_some_and(|old| old.same_but_bounds(element));
if !unchanged {
entry.update(document, id, shown, element, page, &mut self.attributes)?;
}
entry
}
std::collections::hash_map::Entry::Vacant(entry) => entry.insert(MirrorEntry::new(
document,
id,
element,
page,
&mut self.attributes.now,
)?),
};
entry.place(placement.rect(element.bounds), &mut self.css)?;
self.links.node_ids.borrow_mut().insert(id, element.node_id);
if edits_text(element) {
self.text_input
.fields
.borrow_mut()
.insert(element.node_id, entry.node.clone());
}
let parent = element
.scroll_parent
.and_then(|parent| parents.get(&parent).copied());
self.orders.push(id, entry, parent, shown.is_some());
}
self.orders.write(&self.root, &self.entries)
}
fn settle_focus(
&mut self,
held: Option<String>,
app_focus_before: Option<i32>,
) -> Result<(), JsValue> {
if !self.previous.elements.iter().any(|element| element.focused) {
self.focused_element = None;
}
let Some(selector) = held.filter(|_| self.focused_element == app_focus_before) else {
return Ok(());
};
if let Some(node) = self.root.query_selector(&selector)?
&& let Ok(node) = node.dyn_into::<HtmlElement>()
&& !node.matches(":focus")?
{
focus_field(&node)?;
}
Ok(())
}
}
fn reader_focus(document: &Document) -> Option<String> {
let node = document.active_element()?;
if let Some(id) = node.get_attribute("data-cranpose-node") {
let id = id.parse::<i32>().ok()?;
Some(format!("[data-cranpose-node=\"{id}\"]"))
} else {
let id = node
.get_attribute("data-cranpose-action-node")?
.parse::<i32>()
.ok()?;
let index = node
.get_attribute("data-cranpose-action")?
.parse::<usize>()
.ok()?;
Some(format!(
"[data-cranpose-action-node=\"{id}\"][data-cranpose-action=\"{index}\"]"
))
}
}