Skip to main content

cranpose_ui/
announce.rs

1use std::cell::RefCell;
2
3use cranpose_core::CompositionLocal;
4use cranpose_foundation::LiveRegionMode;
5
6const QUEUE_LIMIT: usize = 32;
7
8/// Text a screen reader should read out, with no control to move to.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct Announcement {
11    pub text: String,
12    pub mode: LiveRegionMode,
13}
14
15/// Reads text out on a screen reader without moving focus, the way
16/// `View.announceForAccessibility` does under Compose on Android.
17///
18/// ```ignore
19/// let reader = cranpose_ui::local_announcer().current();
20/// reader.announce("Seven receipts imported");
21/// reader.announce_assertive("Import failed");
22/// ```
23///
24/// Use this for an event with no control behind it. When a control on screen
25/// holds the text, mark that control with `Modifier::semantics(|config| {
26/// config.live_region = Some(LiveRegionMode::Polite) })` instead, so the reader
27/// can also move to it and read it again.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub struct Announcer;
30
31impl Announcer {
32    /// Reads `text` once the reader finishes what it says now.
33    pub fn announce(&self, text: impl Into<String>) {
34        push(text.into(), LiveRegionMode::Polite);
35    }
36
37    /// Cuts off what the reader says now and reads `text` at once.
38    pub fn announce_assertive(&self, text: impl Into<String>) {
39        push(text.into(), LiveRegionMode::Assertive);
40    }
41}
42
43/// CompositionLocal carrying the [`Announcer`]. The same instance comes back on
44/// every call.
45pub fn local_announcer() -> CompositionLocal<Announcer> {
46    crate::environment_locals::cached_local(
47        |locals| &locals.announcer,
48        || cranpose_core::compositionLocalOf(Announcer::default),
49    )
50}
51
52/// Reads `text` out politely. Short form of [`Announcer::announce`] for code
53/// that sits outside composition.
54pub fn announce(text: impl Into<String>) {
55    Announcer.announce(text);
56}
57
58/// Takes the queued announcements. A platform accessibility bridge calls this
59/// once a frame and hands each one to the screen reader.
60pub fn drain_announcements() -> Vec<Announcement> {
61    queue(std::mem::take)
62}
63
64/// How many announcements wait for a bridge to take them.
65pub fn pending_announcements() -> usize {
66    queue(|pending| pending.len())
67}
68
69fn push(text: String, mode: LiveRegionMode) {
70    if text.trim().is_empty() {
71        return;
72    }
73    queue(|pending| {
74        if pending.len() >= QUEUE_LIMIT {
75            pending.remove(0);
76        }
77        pending.push(Announcement { text, mode });
78    });
79}
80
81fn queue<T>(action: impl FnOnce(&mut Vec<Announcement>) -> T) -> T {
82    thread_local! {
83        static PENDING: RefCell<Vec<Announcement>> = const { RefCell::new(Vec::new()) };
84    }
85
86    PENDING.with(|cell| action(&mut cell.borrow_mut()))
87}
88
89#[cfg(test)]
90#[path = "tests/announce_tests.rs"]
91mod tests;