Skip to main content

ravenrs_app_a11y/
announce.rs

1//! Telling screen readers what changed, without moving the keyboard.
2//!
3//! An [`Announcer`] keeps the latest announcement; its [`LiveRegion`] shows it to assistive
4//! technology as a status, which waits for a pause, or as an alert, which interrupts. Nothing of
5//! it shows on the screen. Each announcement is a new node, so the same words said twice are
6//! heard twice, and the last few stay, so one said right after another does not take its place.
7
8use std::cell::Cell;
9use std::rc::Rc;
10
11use ravenrs_core::Role;
12use ravenrs_layout::LayoutStyle;
13use ravenrs_reactive::State;
14use ravenrs_widgets::{BuildCx, Keyed, NodeId, Paint, Semantics, View};
15
16/// How many announcements the region keeps, newest last.
17const KEPT: usize = 3;
18
19/// How soon an announcement is heard.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub enum Urgency {
22    /// When the screen reader is done with what it is saying: something that went as asked.
23    #[default]
24    Polite,
25    /// At once: something that went wrong, or that cannot wait.
26    Assertive,
27}
28
29/// One thing said to screen readers.
30#[derive(Clone, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct Announcement {
33    /// The words.
34    pub text: String,
35    /// How soon.
36    pub urgency: Urgency,
37    /// Which one it is, counting from the first.
38    pub serial: u64,
39}
40
41/// Where an application says what changed. A handle: clones say it in the same place.
42///
43/// ```
44/// use ravenrs_app_a11y::{Announcer, Urgency};
45/// use ravenrs_core::Role;
46/// use ravenrs_widgets::Ui;
47///
48/// let announcer = Announcer::new();
49/// let mut ui = Ui::new(announcer.region(), ravenrs_style::Theme::raven_dark());
50/// announcer.polite("Salvo: Conta corrente");
51/// ui.update();
52/// assert!(ui.access_tree().find(Role::Status, "Salvo: Conta corrente").is_some());
53/// announcer.assertive("Não foi salvo");
54/// ui.update();
55/// assert!(ui.access_tree().find(Role::Alert, "Não foi salvo").is_some());
56/// assert_eq!(announcer.latest().map(|a| a.urgency), Some(Urgency::Assertive));
57/// ```
58#[derive(Clone, Debug, Default)]
59pub struct Announcer {
60    recent: State<Vec<Announcement>>,
61    count: Rc<Cell<u64>>,
62    quiet: Rc<Cell<bool>>,
63}
64
65impl Announcer {
66    /// With nothing said yet.
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Says `text` when the screen reader pauses.
72    pub fn polite(&self, text: impl Into<String>) {
73        self.say(text.into(), Urgency::Polite);
74    }
75
76    /// Says `text` at once.
77    pub fn assertive(&self, text: impl Into<String>) {
78        self.say(text.into(), Urgency::Assertive);
79    }
80
81    /// Says `text` this soon. Empty words say nothing.
82    pub fn say(&self, text: String, urgency: Urgency) {
83        if self.quiet.get() || text.trim().is_empty() {
84            return;
85        }
86        let serial = self.count.get() + 1;
87        self.count.set(serial);
88        self.recent.update(|recent| {
89            recent.push(Announcement { text, urgency, serial });
90            let extra = recent.len().saturating_sub(KEPT);
91            recent.drain(..extra);
92        });
93    }
94
95    /// Stops saying anything, or starts again, for an application that announces in its own way.
96    pub fn set_quiet(&self, quiet: bool) {
97        self.quiet.set(quiet);
98    }
99
100    /// Whether it says nothing.
101    pub fn is_quiet(&self) -> bool {
102        self.quiet.get()
103    }
104
105    /// The latest thing said.
106    pub fn latest(&self) -> Option<Announcement> {
107        self.recent.peek().last().cloned()
108    }
109
110    /// The last few things said, newest last.
111    pub fn recent(&self) -> Vec<Announcement> {
112        self.recent.peek().clone()
113    }
114
115    /// Where screen readers hear it. It takes no room.
116    pub fn region(&self) -> LiveRegion {
117        LiveRegion { recent: self.recent.clone() }
118    }
119}
120
121/// What an [`Announcer`] says, as a node of the accessibility tree and nothing on the screen.
122#[derive(Clone, Debug)]
123pub struct LiveRegion {
124    recent: State<Vec<Announcement>>,
125}
126
127/// The node of one announcement.
128struct Said(Announcement);
129
130impl View for Said {
131    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
132        let node = cx.container(
133            LayoutStyle { width: Some(0.0), height: Some(0.0), ..LayoutStyle::leaf() },
134            &[],
135            Paint::None,
136        );
137        let role = match self.0.urgency {
138            Urgency::Polite => Role::Status,
139            Urgency::Assertive => Role::Alert,
140        };
141        cx.semantics(node, Semantics::new(role).name(self.0.text.clone()));
142        node
143    }
144}
145
146impl View for LiveRegion {
147    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
148        let said: Vec<NodeId> = self
149            .recent
150            .get()
151            .into_iter()
152            .map(|said| cx.child(&Keyed::new(said.serial, Said(said))))
153            .collect();
154        cx.container(
155            LayoutStyle { width: Some(0.0), height: Some(0.0), ..LayoutStyle::leaf() },
156            &said,
157            Paint::None,
158        )
159    }
160}