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    thread_local! {
47        static LOCAL_ANNOUNCER: RefCell<Option<CompositionLocal<Announcer>>> =
48            const { RefCell::new(None) };
49    }
50
51    LOCAL_ANNOUNCER.with(|cell| {
52        cell.borrow_mut()
53            .get_or_insert_with(|| cranpose_core::compositionLocalOf(Announcer::default))
54            .clone()
55    })
56}
57
58/// Reads `text` out politely. Short form of [`Announcer::announce`] for code
59/// that sits outside composition.
60pub fn announce(text: impl Into<String>) {
61    Announcer.announce(text);
62}
63
64/// Takes the queued announcements. A platform accessibility bridge calls this
65/// once a frame and hands each one to the screen reader.
66pub fn drain_announcements() -> Vec<Announcement> {
67    queue(std::mem::take)
68}
69
70/// How many announcements wait for a bridge to take them.
71pub fn pending_announcements() -> usize {
72    queue(|pending| pending.len())
73}
74
75fn push(text: String, mode: LiveRegionMode) {
76    if text.trim().is_empty() {
77        return;
78    }
79    queue(|pending| {
80        if pending.len() >= QUEUE_LIMIT {
81            pending.remove(0);
82        }
83        pending.push(Announcement { text, mode });
84    });
85}
86
87fn queue<T>(action: impl FnOnce(&mut Vec<Announcement>) -> T) -> T {
88    thread_local! {
89        static PENDING: RefCell<Vec<Announcement>> = const { RefCell::new(Vec::new()) };
90    }
91
92    PENDING.with(|cell| action(&mut cell.borrow_mut()))
93}
94
95#[cfg(test)]
96#[path = "tests/announce_tests.rs"]
97mod tests;