cranpose_ui/announce.rs
1use cranpose_core::CompositionLocal;
2use cranpose_foundation::LiveRegionMode;
3
4const QUEUE_LIMIT: usize = 32;
5
6/// Text a screen reader should read out, with no control to move to.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct Announcement {
9 /// The text the screen reader reads.
10 pub text: String,
11 /// Whether the reader waits for its current speech to end or cuts it off.
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 crate::thread_state::with(|state| action(&mut state.pending_announcements.borrow_mut()))
83}
84
85#[cfg(test)]
86#[path = "tests/announce_tests.rs"]
87mod tests;