frus-shell 0.3.0

frus — the platform layer: window + event loop (Milestone 0)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! The **accessibility** bridge for the web: maps frus's semantic tree
//! ([`frus_widgets::Ui::semantics`]) onto real DOM elements, so a browser's screen
//! reader (NVDA, JAWS, VoiceOver, Narrator) can read and operate the interface.
//!
//! AccessKit has no web adapter — its own README lists the web as a **planned**
//! platform, not a shipped one, and `accesskit_winit` attaches to nothing on
//! `wasm32`. What the web *does* have, natively, is the accessible-canvas technique
//! the platform itself defines: content nested inside a `<canvas>` element is not
//! painted when the canvas renders (so it never doubles what the GPU already drew),
//! but it is still walked into the accessibility tree, focusable by `Tab`, and
//! reachable by a screen reader's own navigation — exactly the fallback the `<canvas>`
//! element was specified with. This module keeps the DOM in step with the same
//! `(WidgetId, Rect, SemanticsProperties)` list the desktop bridge reads, one real
//! element per node (a `<button>` when the node is clickable, so `Enter` and `Space`
//! activate it without a hand-rolled key handler; a `<div>` otherwise), diffed against
//! last frame's set rather than rebuilt from nothing.
//!
//! What this does **not** do: give a node a bounding box a touch screen reader (mobile
//! VoiceOver, TalkBack) could use for spatial "explore by touch" — canvas fallback
//! content is unpainted, so it is never laid out and `getBoundingClientRect` on it is
//! always empty. A keyboard-driven or virtual-cursor screen reader does not need one;
//! touch exploration is left for a future pass, noted in the ROADMAP.

use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Element, HtmlCanvasElement, HtmlElement};
use winit::platform::web::WindowExtWebSys;
use winit::window::Window;

use frus_widgets::{Rect, Role, SemanticsProperties, Toggled, WidgetId};

/// The ARIA role for a frus role — the web's counterpart to `a11y::to_ak_role`.
fn aria_role(role: Role) -> &'static str {
    match role {
        Role::None => "group",
        Role::Label => "text",
        Role::Heading => "heading",
        Role::Button => "button",
        Role::Link => "link",
        Role::CheckBox => "checkbox",
        Role::Switch => "switch",
        Role::RadioButton => "radio",
        Role::Slider => "slider",
        Role::TextInput => "textbox",
        Role::Image => "img",
        Role::Tab => "tab",
        Role::ListItem => "listitem",
        Role::ProgressBar => "progressbar",
    }
}

/// An action a screen reader asked for, replayed by the shell's main loop — the same
/// shape as `a11y::A11yAction`, kept as its own type since the two bridges share no
/// code below this file.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum A11yAction {
    /// Activate the widget, that is, click it.
    Click(WidgetId),
    /// Give the widget focus.
    Focus(WidgetId),
}

type Actions = Rc<RefCell<Vec<A11yAction>>>;

/// One live DOM node backing a widget's accessibility, and the listeners keeping it
/// wired — dropped together with the element when the widget leaves the tree.
struct Live {
    el: HtmlElement,
    clickable: bool,
    _click: Option<Closure<dyn FnMut(web_sys::Event)>>,
    _keys: Option<Closure<dyn FnMut(web_sys::Event)>>,
    _focus: Closure<dyn FnMut(web_sys::Event)>,
}

/// A `keydown` listener for a **button**: an Enter or a Space on it is the browser's own
/// "activate", which fires the `click` this module listens for, and must not *also* reach
/// the shell's key handler — which would read it as a key of whatever widget has the focus.
/// Found checking the selection bar in a browser (milestone 568): an Enter on the bar's
/// *Select all* button reached the text field the bar acts on, and submitted it.
fn key_listener() -> Closure<dyn FnMut(web_sys::Event)> {
    Closure::wrap(Box::new(move |event: web_sys::Event| {
        let activates = event
            .dyn_ref::<web_sys::KeyboardEvent>()
            .is_some_and(|key| matches!(key.key().as_str(), "Enter" | " "));
        if activates {
            event.stop_propagation();
        }
    }) as Box<dyn FnMut(web_sys::Event)>)
}

/// Attaches a `click` listener that queues an [`A11yAction::Click`] and **wakes the
/// loop** — the web only ever runs a frame it was asked for (milestone 561 found the
/// same gap in the address bar's own `popstate`), and a screen reader's click arrives
/// with nothing else pending a redraw.
fn click_listener(
    id: WidgetId,
    actions: Actions,
    window: Arc<Window>,
) -> Closure<dyn FnMut(web_sys::Event)> {
    Closure::wrap(Box::new(move |_event: web_sys::Event| {
        actions.borrow_mut().push(A11yAction::Click(id));
        window.request_redraw();
    }) as Box<dyn FnMut(web_sys::Event)>)
}

/// Attaches a `focus` listener that queues an [`A11yAction::Focus`] and wakes the loop
/// — unless the focus just moved here **because we called `.focus()` ourselves**
/// (`programmatic`), in which case it is an echo, not a request, and queuing it would
/// bounce forever.
fn focus_listener(
    id: WidgetId,
    actions: Actions,
    programmatic: Rc<Cell<bool>>,
    window: Arc<Window>,
) -> Closure<dyn FnMut(web_sys::Event)> {
    Closure::wrap(Box::new(move |_event: web_sys::Event| {
        if !programmatic.get() {
            actions.borrow_mut().push(A11yAction::Focus(id));
            window.request_redraw();
        }
    }) as Box<dyn FnMut(web_sys::Event)>)
}

/// A window's **live** accessibility bridge, projected into the canvas's own fallback
/// content.
pub(crate) struct A11y {
    canvas: HtmlCanvasElement,
    live_region: HtmlElement,
    nodes: HashMap<WidgetId, Live>,
    actions: Actions,
    /// Set for the duration of a `.focus()` call we made ourselves, so the `focus`
    /// listener it fires is not queued back as an assistive-technology request.
    programmatic_focus: Rc<Cell<bool>>,
    focus: Option<WidgetId>,
    announce: String,
    /// Cloned into every listener, so it can wake the loop (`request_redraw`) — the
    /// web only runs a frame it was asked for, and a screen reader's action is not
    /// otherwise anything winit knows to redraw for.
    window: Arc<Window>,
}

impl A11y {
    /// Creates the bridge over `window`'s canvas. `None` if winit has not attached one
    /// (should not happen on the web target, but nothing here is worth a panic over).
    pub(crate) fn new(window: &Arc<Window>) -> Option<Self> {
        let canvas = window.canvas()?;
        canvas.set_attribute("role", "application").ok();
        let live_region = new_live_region(&canvas)?;
        Some(Self {
            canvas,
            live_region,
            nodes: HashMap::new(),
            actions: Rc::new(RefCell::new(Vec::new())),
            programmatic_focus: Rc::new(Cell::new(false)),
            focus: None,
            announce: String::new(),
            window: window.clone(),
        })
    }

    /// Publishes the frame's semantic tree: creates the elements for new widgets,
    /// updates the ones already there, and removes the ones no longer present.
    pub(crate) fn update(
        &mut self,
        nodes: &[(WidgetId, Rect, SemanticsProperties)],
        focus: Option<WidgetId>,
        title: &str,
        announce: &str,
    ) {
        self.canvas.set_attribute("aria-label", title).ok();

        let present: std::collections::HashSet<WidgetId> =
            nodes.iter().map(|(id, _, _)| *id).collect();
        // The element that has the browser's focus, if it is one of ours: when it is taken out
        // of the page below, the browser drops the focus on the document, and the keys that
        // followed (an Escape, a Tab) would reach no one. Found checking the selection bar
        // (milestone 568): activating one of its buttons replaces the bar's elements.
        let active = self
            .canvas
            .owner_document()
            .and_then(|document| document.active_element());
        let is_active = |el: &HtmlElement| {
            active
                .as_ref()
                .is_some_and(|active| active == AsRef::<web_sys::Element>::as_ref(el))
        };
        let mut lost_focus = false;
        self.nodes.retain(|id, live| {
            let keep = present.contains(id);
            if !keep {
                lost_focus |= is_active(&live.el);
                let _ = self.canvas.remove_child(&live.el);
            }
            keep
        });

        for (id, _rect, sem) in nodes {
            match self.nodes.get(id) {
                Some(live) if live.clickable == sem.clickable => {
                    apply(&live.el, sem);
                }
                _ => {
                    // Either new, or its element was built for the wrong tag (a
                    // clickable widget needs the `<button>`, a native `Enter`/`Space`
                    // activator a `<div>` cannot offer).
                    if let Some(old) = self.nodes.remove(id) {
                        lost_focus |= is_active(&old.el);
                        let _ = self.canvas.remove_child(&old.el);
                    }
                    if let Some(live) = self.spawn(*id, sem) {
                        apply(&live.el, sem);
                        self.nodes.insert(*id, live);
                    }
                }
            }
        }

        if focus != self.focus || lost_focus {
            self.focus = focus;
            if let Some(id) = focus {
                if let Some(live) = self.nodes.get(&id) {
                    self.programmatic_focus.set(true);
                    let _ = live.el.focus();
                    self.programmatic_focus.set(false);
                }
            }
        }

        if announce != self.announce {
            self.announce = announce.to_string();
            self.live_region.set_text_content(Some(announce));
        }
    }

    /// Builds the DOM element for a new widget and attaches its listeners.
    fn spawn(&self, id: WidgetId, sem: &SemanticsProperties) -> Option<Live> {
        let document = self.canvas.owner_document()?;
        let tag = if sem.clickable { "button" } else { "div" };
        let el: HtmlElement = document
            .create_element(tag)
            .ok()?
            .dyn_into::<HtmlElement>()
            .ok()?;
        if sem.clickable {
            el.set_attribute("type", "button").ok();
        } else {
            // Not a Tab stop (only interactive nodes are), but still a target the
            // shell can move real focus onto when its own model says so.
            el.set_attribute("tabindex", "-1").ok();
        }
        let click = sem.clickable.then(|| {
            let closure = click_listener(id, self.actions.clone(), self.window.clone());
            let _ = (el.as_ref() as &web_sys::EventTarget)
                .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
            closure
        });
        let keys = sem.clickable.then(|| {
            let closure = key_listener();
            let _ = (el.as_ref() as &web_sys::EventTarget)
                .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
            closure
        });
        let focus_closure = focus_listener(
            id,
            self.actions.clone(),
            self.programmatic_focus.clone(),
            self.window.clone(),
        );
        let _ = (el.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("focus", focus_closure.as_ref().unchecked_ref());
        self.canvas.append_child(&el).ok()?;
        Some(Live {
            el,
            clickable: sem.clickable,
            _click: click,
            _keys: keys,
            _focus: focus_closure,
        })
    }

    /// Takes the actions the screen reader asked for since the last frame, emptying
    /// the queue.
    pub(crate) fn take_actions(&self) -> Vec<A11yAction> {
        std::mem::take(&mut self.actions.borrow_mut())
    }
}

/// Creates the **live region**: an invisible, polite status the assistive technology
/// speaks when its text **changes** — `aria-live="polite"`, the same contract the
/// desktop bridge gives AccessKit's `Live::Polite`.
///
/// **Not** canvas fallback content, unlike every other node this module builds.
/// Found testing #18 in a real browser: a live region nested inside an unpainted
/// `<canvas>` child never reached Chrome's accessibility tree at all — its role and
/// interactive nodes did, but the live-region machinery, which watches the *render*
/// tree for mutations, has nothing to watch in a subtree the canvas never paints. This
/// one is a real, laid-out sibling instead, hidden the ordinary "visually hidden but
/// present" way (a 1×1 box, clipped, never `display: none`) rather than by being
/// outside the render tree altogether.
fn new_live_region(canvas: &HtmlCanvasElement) -> Option<HtmlElement> {
    let document = canvas.owner_document()?;
    let body = document.body()?;
    let el: HtmlElement = document
        .create_element("div")
        .ok()?
        .dyn_into::<HtmlElement>()
        .ok()?;
    el.set_attribute("aria-live", "polite").ok();
    el.set_attribute("role", "status").ok();
    el.set_attribute(
        "style",
        "position:absolute;width:1px;height:1px;overflow:hidden;\
         clip:rect(0,0,0,0);white-space:nowrap;",
    )
    .ok();
    body.append_child(&el).ok()?;
    Some(el)
}

/// Writes a frus annotation onto an already-built element: role, name, state — every
/// frame, since any of them may have changed.
fn apply(el: &HtmlElement, sem: &SemanticsProperties) {
    let element: &Element = el.as_ref();
    element.set_attribute("role", aria_role(sem.role)).ok();
    if let Some(label) = &sem.label {
        element.set_attribute("aria-label", label).ok();
    } else {
        element.remove_attribute("aria-label").ok();
    }
    match sem.toggled {
        Toggled::None => {
            element.remove_attribute("aria-checked").ok();
        }
        Toggled::False => {
            element.set_attribute("aria-checked", "false").ok();
        }
        Toggled::True => {
            element.set_attribute("aria-checked", "true").ok();
        }
        Toggled::Mixed => {
            element.set_attribute("aria-checked", "mixed").ok();
        }
    }
    if sem.disabled {
        element.set_attribute("aria-disabled", "true").ok();
        if sem.clickable {
            element.set_attribute("disabled", "").ok();
        }
    } else {
        element.remove_attribute("aria-disabled").ok();
        if sem.clickable {
            element.remove_attribute("disabled").ok();
        }
    }
    if let Some((min, value, max)) = sem.range {
        element
            .set_attribute("aria-valuemin", &min.to_string())
            .ok();
        element
            .set_attribute("aria-valuenow", &value.to_string())
            .ok();
        element
            .set_attribute("aria-valuemax", &max.to_string())
            .ok();
    } else {
        element.remove_attribute("aria-valuemin").ok();
        element.remove_attribute("aria-valuenow").ok();
        element.remove_attribute("aria-valuemax").ok();
    }
    // A role with no dedicated ARIA value slot (a badge on a button, a caption under a
    // step) carries its `value` as a description read after the label; a field's
    // contents are its accessible **value**, read through the element's own text
    // rather than a made-up attribute, matching how a browser resolves a native
    // `role="textbox"` with no editable child.
    match (&sem.value, sem.role) {
        (Some(value), Role::TextInput) => el.set_text_content(Some(value)),
        (Some(value), Role::Slider | Role::ProgressBar) => {
            element.set_attribute("aria-valuetext", value).ok();
        }
        (Some(value), _) => {
            element.set_attribute("aria-description", value).ok();
        }
        (None, Role::TextInput) => el.set_text_content(None),
        (None, _) => {
            element.remove_attribute("aria-description").ok();
            element.remove_attribute("aria-valuetext").ok();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roles_map_to_aria() {
        assert_eq!(aria_role(Role::Button), "button");
        assert_eq!(aria_role(Role::CheckBox), "checkbox");
        assert_eq!(aria_role(Role::Slider), "slider");
        assert_eq!(aria_role(Role::ProgressBar), "progressbar");
        assert_eq!(aria_role(Role::TextInput), "textbox");
    }
}