open-standards/aac/aac-rs/crates/aac-web/src/switches.rs
Transaction Science 263ffe2c7b i18n(aac-web): the message bar, the board's buttons, Help, the way-out bar, switches and scanning names through the catalogue
Cues for the app's own buttons (auditory scanning, auditory touch) are said
in the language their words are shown in: by an English voice where the
message has no translation yet. The board area and the message carry the
person's language (lang), whatever language the app's words are in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:08:41 -04:00

562 lines
26 KiB
Rust

//! The person's switches: which key, gamepad button or tap is which switch
//! (access.rs `AccessPrefs::switches`, taught in Settings → Switches by
//! pressing the switch), and what a press does.
//!
//! Each switch is read on its own: a press counts unless that switch is down
//! already, or was let go less than the release time ago (a bouncing
//! contact, a hand that shakes as it lets go); and when the person set a
//! hold time, only once it has been held that long. Then it does its job
//! (access.rs `switch_job`): in switch scanning, select, move the highlight
//! on, move it back, or back out of a row; while pointing or looking, switch
//! 1 or 2 selects what is pointed at, when the person asked for that.
//!
//! In inverse scanning switch 1 held lets the highlight move (the timer
//! moves it while it is down), and letting go selects.
//!
//! **The way out** (SPEC §5.7 item 8): a person is locked out when their
//! switch does nothing any more (it was taught away, or the access method
//! was changed to one it has no part in). Holding such a key or gamepad
//! button — one that does nothing for them now — for [`ESCAPE_MS`] brings
//! switch scanning back with it as switch 1, keeping their other switches,
//! and what moves the highlight where it still works; it is provisional,
//! kept by pressing that switch again once it has come up (a bounce as it
//! comes up does not keep it). For someone who looks, points or touches,
//! only their switches count: a hand resting on a laptop's keyboard is
//! never the way out. A switch that works for them is never
//! counted, however long it is held (resting on it, or holding it in
//! inverse scanning, is using it); nor are keys pressed with ⌘, Ctrl or Alt,
//! modifiers, or the keys a keyboard holds down to move and scroll.
use crate::access::SwitchInput;
/// The input a key event is: its code (where it is on the keyboard), named
/// by the key it types. A key event without a code (some on-screen
/// keyboards send none) goes by the key.
pub fn key_input(code: &str, key: &str) -> SwitchInput {
let code = match (code, key) {
("", " ") => "Space".to_string(), // i18n: not UI (a key's code)
("", k) => k.to_string(),
(c, _) => c.to_string(),
};
let name = match key {
" " => "Space".to_string(), // i18n: not UI (a stored key name; access.rs describe() names it)
k if k.chars().count() == 1 => k.to_uppercase(),
k => k.to_string(),
};
SwitchInput::Key { code, name }
}
#[cfg(feature = "hydrate")]
pub use browser::*;
#[cfg(feature = "hydrate")]
mod browser {
use aac_core::{AccessMethod, ScanAdvance};
use leptos::prelude::*;
use crate::access::{SwitchInput, SwitchJob, switch_job};
use crate::state::AacAppState;
/// How long "press your switch" waits before it gives up.
const LEARN_MS: u32 = 15_000;
/// How long holding a key or button that does nothing for the person
/// brings switch scanning back (the way out).
pub const ESCAPE_MS: u32 = 8_000;
thread_local! {
/// The teaching was started by switch scanning or dwell: what is
/// taught is provisional (provisional.rs).
static LEARN_BY_ACCESS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// The keys and buttons down now that do nothing for the person, each
/// with its press number, for the way out.
static HELD: std::cell::RefCell<std::collections::HashMap<String, u64>> = std::cell::RefCell::new(std::collections::HashMap::new());
/// The key or button whose hold brought the way out, until it comes
/// up: its key-up presses nothing.
static WAY_OUT_BY: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
/// When it came up (a press that keeps the way out comes after, by
/// at least the release time).
static WAY_OUT_UP_AT: std::cell::Cell<f64> = const { std::cell::Cell::new(f64::NEG_INFINITY) };
static HOLDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
thread_local! {
/// Someone helping pressed Tab in an open panel: the keys there are
/// the keyboard's until the panel closes or a switch that is not a
/// key is pressed.
static KEYBOARD_HELPER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Tab pressed (and not a switch) with a panel open: someone is working
/// the panel with a keyboard.
pub fn keyboard_helper_started() {
KEYBOARD_HELPER.with(|k| k.set(true));
}
/// Whether a key on a control of the open panel is the keyboard's, not a
/// switch press: someone helping moved into the panel with Tab.
pub fn keyboard_helper_has_keys() -> bool {
if !crate::actions::panel_open() {
KEYBOARD_HELPER.with(|k| k.set(false));
}
KEYBOARD_HELPER.with(std::cell::Cell::get)
}
/// Whether a tap is one of the person's switches now: a switch taught,
/// switch scanning on, and no board being edited.
pub fn tap_is_switch_now(state: &AacAppState) -> bool {
matches!(state.profile.access_method.get_untracked(), AccessMethod::SwitchScan { .. })
&& !state.board.edit_mode.get_untracked()
&& state.profile.access.with_untracked(|a| a.tap_is_switch())
}
/// Which switch `input` is, for the person.
pub fn switch_of(state: &AacAppState, input: &SwitchInput) -> Option<usize> {
state.profile.access.with_untracked(|a| a.switch_of(input))
}
/// Start teaching switch `n`: the next key, gamepad button or tap (the
/// learning panel's own tap target) becomes it.
pub fn start_learning(state: &AacAppState, n: usize) {
LEARN_BY_ACCESS.with(|l| l.set(crate::provisional::by_access()));
state.interaction.switch_learning.set(Some(n));
let learning = state.interaction.switch_learning;
gloo_timers::callback::Timeout::new(LEARN_MS, move || {
if learning.try_get_untracked() == Some(Some(n)) {
learning.set(None);
}
})
.forget();
}
/// Stop teaching, with nothing learned.
pub fn stop_learning(state: &AacAppState) {
state.interaction.switch_learning.set(None);
}
/// Whether "press your switch" is on screen: the teaching is only taken
/// while it is (a press after Settings closed is the person's, not a
/// switch to learn).
fn learning_shown() -> bool {
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector(".switch-learning").ok().flatten())
.is_some()
}
/// While a switch is being taught (and "press your switch" is on
/// screen), `input` becomes it, and true is returned: the press is not
/// acted on. Escape stops the teaching. A change that would leave the
/// person nothing to select with is refused, and said so; one taught by
/// scanning or dwell is provisional.
pub fn learn(state: &AacAppState, input: &SwitchInput) -> bool {
let Some(n) = state.interaction.switch_learning.get_untracked() else { return false };
if !learning_shown() {
stop_learning(state);
return false;
}
if matches!(input, SwitchInput::Key { code, .. } if code == "Escape") { // i18n: not UI (a key code)
stop_learning(state);
return true;
}
let method = state.profile.access_method.get_untracked();
let mut next = state.profile.access.get_untracked();
let message = match next.teach(n, input.clone(), method) {
Ok(()) => {
crate::provisional::guard(state, LEARN_BY_ACCESS.with(|l| l.get()));
state.profile.access.set(next);
crate::i18n::tr_args("switches-learned", &crate::ui_args!(number = n + 1, input = input.describe()))
}
Err(refused) => refused.explain(),
};
state.interaction.switch_learning.set(None);
crate::state::announce(state.chrome.announcement, &message.text);
crate::state::show_toast(state.chrome.toasts, message, crate::state::ToastLevel::Success);
true
}
/// Take switch `n`'s inputs away, unless that would leave nothing to
/// select with (said so). Made by scanning or dwell, provisional.
pub fn clear(state: &AacAppState, n: usize) {
let method = state.profile.access_method.get_untracked();
let mut next = state.profile.access.get_untracked();
match next.clear(n, method) {
Ok(()) => {
crate::provisional::guard(state, false);
state.profile.access.set(next);
}
Err(refused) => {
crate::state::announce(state.chrome.announcement, &refused.explain().text);
crate::state::show_toast(state.chrome.toasts, refused.explain(), crate::state::ToastLevel::Warning);
}
}
}
// ─── The way out ─────────────────────────────────────────────────────
/// Keys never counted for the way out: modifiers, and keys a keyboard
/// holds down to move, scroll or delete. A switch interface sends none
/// of them.
pub fn never_way_out(code: &str) -> bool {
// i18n-off: key codes
matches!(
code,
"ShiftLeft" | "ShiftRight" | "ControlLeft" | "ControlRight" | "AltLeft" | "AltRight" | "MetaLeft"
| "MetaRight" | "OSLeft" | "OSRight" | "CapsLock" | "Fn" | "FnLock" | "NumLock" | "ContextMenu"
| "Tab" | "Escape" | "Backspace" | "Delete" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
| "PageUp" | "PageDown" | "Home" | "End"
) || code.starts_with("Audio")
|| code.starts_with("Media")
|| code.starts_with("Browser")
// i18n-on
}
/// Whether holding `input` counts for the way out (access.rs
/// `way_out_counts`): it does nothing for the person now, and is one of
/// their switches (any input, while they scan).
fn idle_input(state: &AacAppState, input: &SwitchInput) -> bool {
let method = state.profile.access_method.get_untracked();
state.profile.access.with_untracked(|a| a.way_out_counts(input, method))
}
/// A key or button (`id`: "key:KeyA", "pad:4") went down: if it does
/// nothing for the person now and is still down after [`ESCAPE_MS`] —
/// and still does nothing for them — the way out.
pub fn hold_started(state: &AacAppState, input: &SwitchInput, id: String) {
let press = HOLDS.with(|h| {
h.set(h.get() + 1);
h.get()
});
if HELD.with(|h| h.borrow().contains_key(&id)) || !idle_input(state, input) || learning_shown() {
return;
}
HELD.with(|h| h.borrow_mut().insert(id.clone(), press));
let (state, input) = (state.clone(), input.clone());
gloo_timers::callback::Timeout::new(ESCAPE_MS, move || {
let still = HELD.with(|h| h.borrow().get(&id) == Some(&press));
if still {
HELD.with(|h| h.borrow_mut().remove(&id));
if idle_input(&state, &input) {
WAY_OUT_BY.with(|w| *w.borrow_mut() = Some(id));
way_out(&state, &input);
}
}
})
.forget();
}
/// It came up. True when its hold brought the way out: letting it go
/// then presses nothing (the browser would press the button that has
/// the focus).
pub fn hold_ended(id: &str) -> bool {
HELD.with(|h| h.borrow_mut().remove(id));
let was = WAY_OUT_BY.with(|w| {
let mut w = w.borrow_mut();
if w.as_deref() == Some(id) { w.take().is_some() } else { false }
});
if was {
WAY_OUT_UP_AT.with(|u| u.set(crate::clock::now_ms()));
}
was
}
/// How long ago the switch held for the way out came up, if it has
/// (after a reload: long ago). None while it is still down.
pub fn way_out_released_for() -> Option<f64> {
if WAY_OUT_BY.with(|w| w.borrow().is_some()) {
return None;
}
Some(crate::clock::now_ms() - WAY_OUT_UP_AT.with(std::cell::Cell::get))
}
/// No key is held any more: a modifier came up (the keys pressed with
/// it may never send theirs, as on a Mac keyboard), or the page lost the
/// focus or was hidden.
pub fn keys_ended() {
HELD.with(|h| h.borrow_mut().retain(|id, _| !id.starts_with("key:")));
}
/// Nothing is held any more (the page lost the focus).
pub fn holds_ended() {
HELD.with(|h| h.borrow_mut().clear());
}
/// Switch scanning back, with `held` as switch 1 (access.rs `way_out`),
/// and the scanning times from before a change still waiting to be kept
/// — or, with none, times that lock nobody out: no slower scan made
/// faster, no hold time, the usual release time. Provisional, kept by
/// pressing `held` again once it has come up; and said, aloud where a
/// voice can, and on screen.
pub fn way_out(state: &AacAppState, held: &SwitchInput) {
stop_learning(state);
let method = state.profile.access_method.get_untracked();
let (prefs, next) = state.profile.access.with_untracked(|a| a.way_out(held, method));
let waiting = crate::provisional::waiting_timing();
crate::provisional::guard_way_out(state, held);
state.interaction.scan_holding.set(false);
let timing = waiting.unwrap_or_else(|| {
let now = crate::components::shell::timing_now(state);
let usual = aac_core::ScanConfig::default();
crate::persistence::TimingPrefs {
scan_rate_ms: now.scan_rate_ms.max(usual.scan_rate_ms),
scan_auto: false,
switch_hold_ms: 0,
switch_release_ms: usual.release_time_ms,
..now
}
});
crate::components::shell::apply_timing(state, timing);
state.profile.access.set(prefs);
state.profile.access_method.set(next);
crate::components::keyboard::way_out(state);
crate::provisional::made(state);
let message = crate::i18n::tr_args("switches-way-out", &crate::ui_args!(input = held.describe()));
crate::state::announce(state.chrome.announcement, &message.text);
let _ = crate::cues::say_app(state, &message, || {});
}
/// Whether switch `n` does anything with the access method in use: in
/// switch scanning, whenever something is on screen to scan; pointing
/// or looking, switches 1 and 2 when they select what is pointed at.
pub fn in_use(state: &AacAppState, n: usize) -> bool {
// Any switch answers the alphabet by ear, whatever the access method
// — after an open panel, whose scan has the switches first.
if !crate::actions::panel_open() && crate::components::alphabet_by_ear::open(state) {
return true;
}
match state.profile.access_method.get_untracked() {
AccessMethod::SwitchScan { .. } => crate::scan::running(state),
AccessMethod::EyeGaze | AccessMethod::HeadTracking | AccessMethod::Pointer => {
n < 2 && state.profile.access.with_untracked(|a| a.switch_select)
}
_ => false,
}
}
/// Switch `n` went down. It counts as a press unless it is down already
/// (a second press with no release between), or was let go less than
/// the release time ago; and when the person set a hold time
/// (acceptance), only once it has been held that long.
pub fn switch_down(state: &AacAppState, n: usize) {
crate::cues::unlock(state);
let i = &state.interaction;
if i.switch_held.with_untracked(|h| h[n]) {
return;
}
i.switch_held.update(|h| h[n] = true);
let now = crate::clock::now_ms();
let (accept, release) = i.scan_engine.with_untracked(|e| (e.config.acceptance_time_ms, e.config.release_time_ms));
if i.switch_released_at.with_untracked(|r| r[n]).is_some_and(|at| now >= at && now - at < f64::from(release)) {
return;
}
if accept == 0 {
pressed(state, n, now);
return;
}
let press = i.switch_press.with_untracked(|p| p[n]) + 1;
i.switch_press.update(|p| p[n] = press);
let state = state.clone();
gloo_timers::callback::Timeout::new(accept, move || {
let i = &state.interaction;
let still = i.switch_held.try_with_untracked(|h| h[n]) == Some(true)
&& i.switch_press.try_with_untracked(|p| p[n]) == Some(press);
if still && in_use(&state, n) {
pressed(&state, n, now);
}
})
.forget();
}
/// Switch `n` went up (a key or button let go, a finger lifted, or the
/// page losing the focus while it was down): the release time counts
/// from here, and a hold shorter than the acceptance time does not
/// count. In inverse scanning, letting go of switch 1 selects.
pub fn switch_up(state: &AacAppState, n: usize) {
let i = &state.interaction;
if i.switch_held.try_with_untracked(|h| h[n]) != Some(true) {
return;
}
i.switch_held.update(|h| h[n] = false);
let now = crate::clock::now_ms();
i.switch_released_at.update(|r| r[n] = Some(now));
i.switch_press.update(|p| p[n] += 1);
if n == 0 && i.scan_holding.get_untracked() {
i.scan_holding.set(false);
crate::scan::select(state, now);
}
}
/// Every switch let go (the page lost the focus).
pub fn all_up(state: &AacAppState) {
for n in 0..crate::access::SWITCHES {
switch_up(state, n);
}
}
thread_local! {
/// The touch that is a switch press now, by its pointer id.
static TAP_DOWN: std::cell::Cell<Option<i32>> = const { std::cell::Cell::new(None) };
/// That touch's click is not a press of what it landed on.
static TAP_CLICK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// A tap as a switch: once the person taught one, in switch scanning a
/// touch or click on the board, the message or its buttons — or, with a
/// panel open, around it (and on it, once the person has started its
/// scan) — is that switch, down when the finger touches and up when it
/// lifts, and does not also press what it lands on. The navigation bar
/// keeps its taps, so the person helping can open Settings; so do a
/// panel's Close, and "press your switch" while a switch is being taught.
///
/// Only the click the finger itself makes (a trusted event) is held
/// back: the app's own presses of what the switch chose (a button of the
/// bar, Back, a panel's control) are synthetic clicks, and pass. Each
/// touch starts afresh, so a touch the browser cancelled (a long press,
/// a scroll) never leaves a later tap held back.
pub fn listen_taps(state: &AacAppState) {
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::Closure;
let Some(window) = web_sys::window() else { return };
let capture = web_sys::AddEventListenerOptions::new();
capture.set_capture(true);
let down = {
let state = state.clone();
Closure::<dyn Fn(web_sys::PointerEvent)>::new(move |ev: web_sys::PointerEvent| {
TAP_CLICK.with(|t| t.set(false));
let Some(n) = switch_of(&state, &SwitchInput::Tap) else { return };
// Editing a board takes its taps for the editing.
if !matches!(state.profile.access_method.get_untracked(), AccessMethod::SwitchScan { .. })
|| state.board.edit_mode.get_untracked()
{
return;
}
let Some(el) = ev.target().and_then(|t| t.dyn_into::<web_sys::Element>().ok()) else { return };
let within = |sel: &str| el.closest(sel).ok().flatten().is_some();
// A panel or menu over the board (the bar asking to keep a
// change, in the message bar's place, is not one).
let overlay = el
.owner_document()
.and_then(|d| d.query_selector(".panel-overlay, #nav-more-menu, #scan-picker").ok().flatten())
.is_some();
let zone = if overlay {
// While the panel's scan waits (just opened, or someone
// helping is working in it), only the dimmed page around
// it is the switch, and the panel's taps are theirs; once
// the person has started its scan, a tap anywhere is the
// switch. The panel's own Close always keeps its tap.
if crate::components::dialog_scan::running() {
!within(".switch-learning, .toast-container, .panel-close")
} else {
within(".panel-backdrop")
}
} else {
within(".aac-shell") && !within(".navigation-bar, .toast-container, .setup-wizard, .help-banner")
};
if !zone || !in_use(&state, n) {
return;
}
ev.prevent_default();
ev.stop_immediate_propagation();
TAP_DOWN.with(|t| t.set(Some(ev.pointer_id())));
TAP_CLICK.with(|t| t.set(true));
switch_down(&state, n);
})
};
let up = {
let state = state.clone();
Closure::<dyn Fn(web_sys::PointerEvent)>::new(move |ev: web_sys::PointerEvent| {
if TAP_DOWN.with(|t| t.get()) != Some(ev.pointer_id()) {
return;
}
TAP_DOWN.with(|t| t.set(None));
if let Some(n) = switch_of(&state, &SwitchInput::Tap) {
switch_up(&state, n);
}
})
};
let click = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if ev.is_trusted() && TAP_CLICK.with(|t| t.replace(false)) {
ev.prevent_default();
ev.stop_immediate_propagation();
}
});
for (name, f) in [
("pointerdown", down.as_ref()),
("pointerup", up.as_ref()),
("pointercancel", up.as_ref()),
("click", click.as_ref()),
] {
let _ = window.add_event_listener_with_callback_and_add_event_listener_options(name, f.unchecked_ref(), &capture);
}
down.forget();
up.forget();
click.forget();
}
/// A press of switch `n` that counts, made at `down_at`.
fn pressed(state: &AacAppState, n: usize, down_at: f64) {
// The alphabet by ear reads the letters aloud: the switch that
// selects is yes (an open panel first: its scan has the switches).
// Moving switches have nothing to move there.
if !crate::actions::panel_open() && crate::components::alphabet_by_ear::open(state) {
let job = match state.profile.access_method.get_untracked() {
AccessMethod::SwitchScan { .. } => switch_job(n, state.interaction.scan_engine.with_untracked(|e| e.config.advance)),
_ => SwitchJob::Select,
};
if job == SwitchJob::Select {
crate::components::alphabet_by_ear::press(state);
}
return;
}
match state.profile.access_method.get_untracked() {
AccessMethod::SwitchScan { .. } => {
let advance = state.interaction.scan_engine.with_untracked(|e| e.config.advance);
// A switch that is not a key: the person, not a keyboard helper.
if !state.profile.access.with_untracked(|a| {
a.switches[n].iter().any(|i| matches!(i, SwitchInput::Key { .. }))
}) {
KEYBOARD_HELPER.with(|k| k.set(false));
}
// One cell to choose, and nothing else in the scan: every
// switch chooses it at once (proposed SPEC §5.9).
if crate::behaviours::scan_one_target(state).is_some() {
crate::scan::select(state, down_at);
return;
}
match switch_job(n, advance) {
SwitchJob::Select => crate::scan::select(state, down_at),
SwitchJob::Move if advance == ScanAdvance::Hold => {
// The highlight moves while it is held: the first
// step at once, then the timer's.
state.interaction.scan_holding.set(true);
crate::scan::step(state, true);
}
SwitchJob::Move => crate::scan::step(state, true),
SwitchJob::Back => crate::scan::retreat(state),
SwitchJob::Out => crate::scan::back_out(state),
}
}
AccessMethod::EyeGaze | AccessMethod::HeadTracking | AccessMethod::Pointer => {
crate::pointing::switch_select(state);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_key_is_its_place_on_the_keyboard_named_by_what_it_types() {
assert_eq!(key_input("Space", " "), SwitchInput::space());
assert_eq!(key_input("Digit1", "1"), SwitchInput::Key { code: "Digit1".into(), name: "1".into() });
assert_eq!(key_input("KeyA", "a"), SwitchInput::Key { code: "KeyA".into(), name: "A".into() });
assert_eq!(key_input("F7", "F7"), SwitchInput::Key { code: "F7".into(), name: "F7".into() });
// No code (some on-screen keyboards): by the key.
assert_eq!(key_input("", " "), SwitchInput::space());
assert_eq!(key_input("", "Enter"), SwitchInput::enter());
}
}