Skip to main content

ply_engine/
accessibility.rs

1use crate::color::Color;
2use crate::id::Id;
3
4/// Defines the semantic role of a UI element for screen readers and assistive technologies.
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6pub enum AccessibilityRole {
7    #[default]
8    None,
9    // Interactive
10    Button,
11    Link,
12    // Text
13    Heading {
14        level: u8,
15    },
16    Label,
17    StaticText,
18    // Input
19    TextInput,
20    TextArea,
21    Checkbox,
22    RadioButton,
23    Slider,
24    // Containers
25    Group,
26    List,
27    ListItem,
28    Menu,
29    MenuItem,
30    MenuBar,
31    Tab,
32    TabList,
33    TabPanel,
34    Dialog,
35    AlertDialog,
36    Toolbar,
37    // Media
38    Image,
39    ProgressBar,
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum LiveRegionMode {
44    /// No live announcements.
45    #[default]
46    Off,
47    /// Screen reader announces changes politely (waits for current speech to finish).
48    Polite,
49    /// Screen reader announces changes immediately (interrupts current speech).
50    Assertive,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct AccessibilityConfig {
55    pub focusable: bool,
56    pub role: AccessibilityRole,
57    pub label: String,
58    pub description: String,
59    pub value: String,
60    pub value_min: Option<f32>,
61    pub value_max: Option<f32>,
62    pub checked: Option<bool>,
63    pub tab_index: Option<i32>,
64    pub focus_right: Option<u32>,
65    pub focus_left: Option<u32>,
66    pub focus_up: Option<u32>,
67    pub focus_down: Option<u32>,
68    pub show_ring: bool,
69    pub ring_color: Option<Color>,
70    pub ring_width: Option<u16>,
71    pub live_region: LiveRegionMode,
72}
73
74impl AccessibilityConfig {
75    pub fn new() -> Self {
76        Self {
77            show_ring: true,
78            ..Default::default()
79        }
80    }
81}
82
83pub struct AccessibilityBuilder {
84    pub(crate) config: AccessibilityConfig,
85}
86
87impl AccessibilityBuilder {
88    pub(crate) fn new() -> Self {
89        Self {
90            config: AccessibilityConfig::new(),
91        }
92    }
93
94    /// Marks this element as focusable (adds to tab order).
95    pub fn focusable(&mut self) -> &mut Self {
96        self.config.focusable = true;
97        self
98    }
99
100    /// Sets role = Button and label in one call.
101    pub fn button(&mut self, label: &str) -> &mut Self {
102        self.config.role = AccessibilityRole::Button;
103        self.config.label = label.to_string();
104        self.config.focusable = true;
105        self
106    }
107
108    /// Sets role = Heading with the given level (1–6) and label.
109    pub fn heading(&mut self, label: &str, level: u8) -> &mut Self {
110        self.config.role = AccessibilityRole::Heading { level };
111        self.config.label = label.to_string();
112        self
113    }
114
115    /// Sets role = Link and label.
116    pub fn link(&mut self, label: &str) -> &mut Self {
117        self.config.role = AccessibilityRole::Link;
118        self.config.label = label.to_string();
119        self.config.focusable = true;
120        self
121    }
122
123    /// Sets role = StaticText and label. For read-only informational text.
124    pub fn static_text(&mut self, label: &str) -> &mut Self {
125        self.config.role = AccessibilityRole::StaticText;
126        self.config.label = label.to_string();
127        self
128    }
129
130    /// Sets role = Checkbox, label, and focusable.
131    pub fn checkbox(&mut self, label: &str) -> &mut Self {
132        self.config.role = AccessibilityRole::Checkbox;
133        self.config.label = label.to_string();
134        self.config.focusable = true;
135        self
136    }
137
138    /// Sets role = Slider, label, and focusable.
139    pub fn slider(&mut self, label: &str) -> &mut Self {
140        self.config.role = AccessibilityRole::Slider;
141        self.config.label = label.to_string();
142        self.config.focusable = true;
143        self
144    }
145
146    /// Sets role = Image with an alt-text label.
147    pub fn image(&mut self, alt: &str) -> &mut Self {
148        self.config.role = AccessibilityRole::Image;
149        self.config.label = alt.to_string();
150        self
151    }
152
153    /// Sets the role explicitly.
154    pub fn role(&mut self, role: AccessibilityRole) -> &mut Self {
155        self.config.role = role;
156        self
157    }
158
159    /// Sets the accessible label.
160    pub fn label(&mut self, label: &str) -> &mut Self {
161        self.config.label = label.to_string();
162        self
163    }
164
165    /// Sets the accessible description.
166    pub fn description(&mut self, desc: &str) -> &mut Self {
167        self.config.description = desc.to_string();
168        self
169    }
170
171    /// Sets the current value (for sliders, progress bars, etc.).
172    pub fn value(&mut self, value: &str) -> &mut Self {
173        self.config.value = value.to_string();
174        self
175    }
176
177    /// Sets the minimum value.
178    pub fn value_min(&mut self, min: f32) -> &mut Self {
179        self.config.value_min = Some(min);
180        self
181    }
182
183    /// Sets the maximum value.
184    pub fn value_max(&mut self, max: f32) -> &mut Self {
185        self.config.value_max = Some(max);
186        self
187    }
188
189    /// Sets the checked state (for checkboxes/radio buttons).
190    pub fn checked(&mut self, checked: bool) -> &mut Self {
191        self.config.checked = Some(checked);
192        self
193    }
194
195    /// Sets the explicit tab index. Elements without a tab_index
196    /// follow insertion order.
197    pub fn tab_index(&mut self, index: i32) -> &mut Self {
198        self.config.tab_index = Some(index);
199        self
200    }
201
202    /// When the right arrow key is pressed while this element is focused,
203    /// focus moves to the given target element.
204    pub fn focus_right(&mut self, target: impl Into<Id>) -> &mut Self {
205        self.config.focus_right = Some(target.into().id);
206        self
207    }
208
209    /// When the left arrow key is pressed while this element is focused,
210    /// focus moves to the given target element.
211    pub fn focus_left(&mut self, target: impl Into<Id>) -> &mut Self {
212        self.config.focus_left = Some(target.into().id);
213        self
214    }
215
216    /// When the up arrow key is pressed while this element is focused,
217    /// focus moves to the given target element.
218    pub fn focus_up(&mut self, target: impl Into<Id>) -> &mut Self {
219        self.config.focus_up = Some(target.into().id);
220        self
221    }
222
223    /// When the down arrow key is pressed while this element is focused,
224    /// focus moves to the given target element.
225    pub fn focus_down(&mut self, target: impl Into<Id>) -> &mut Self {
226        self.config.focus_down = Some(target.into().id);
227        self
228    }
229
230    /// Disables the automatic focus ring on this element.
231    pub fn disable_ring(&mut self) -> &mut Self {
232        self.config.show_ring = false;
233        self
234    }
235
236    /// Sets the color of the focus ring. Default is red `(255, 60, 40)`.
237    pub fn ring_color(&mut self, color: impl Into<Color>) -> &mut Self {
238        self.config.ring_color = Some(color.into());
239        self
240    }
241
242    /// Sets the width (thickness) of the focus ring in pixels. Default is `2`.
243    pub fn ring_width(&mut self, width: u16) -> &mut Self {
244        self.config.ring_width = Some(width);
245        self
246    }
247
248    /// Sets the live region to polite — screen reader announces changes on next idle.
249    pub fn live_region_polite(&mut self) -> &mut Self {
250        self.config.live_region = LiveRegionMode::Polite;
251        self
252    }
253
254    /// Sets the live region to assertive — screen reader interrupts to announce changes.
255    pub fn live_region_assertive(&mut self) -> &mut Self {
256        self.config.live_region = LiveRegionMode::Assertive;
257        self
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn builder_button_sets_role_and_focusable() {
267        let mut builder = AccessibilityBuilder::new();
268        builder.button("Submit");
269        assert_eq!(builder.config.role, AccessibilityRole::Button);
270        assert_eq!(builder.config.label, "Submit");
271        assert!(builder.config.focusable);
272        assert!(builder.config.show_ring); // default on
273    }
274
275    #[test]
276    fn builder_heading_sets_level() {
277        let mut builder = AccessibilityBuilder::new();
278        builder.heading("Settings", 2);
279        assert_eq!(
280            builder.config.role,
281            AccessibilityRole::Heading { level: 2 }
282        );
283        assert_eq!(builder.config.label, "Settings");
284    }
285
286    #[test]
287    fn builder_disable_ring() {
288        let mut builder = AccessibilityBuilder::new();
289        builder.focusable().disable_ring();
290        assert!(builder.config.focusable);
291        assert!(!builder.config.show_ring);
292    }
293
294    #[test]
295    fn builder_focus_directions() {
296        let mut builder = AccessibilityBuilder::new();
297        builder
298            .focusable()
299            .focus_right(("next", 0u32))
300            .focus_left(("prev", 0u32))
301            .focus_up(("above", 0u32))
302            .focus_down(("below", 0u32));
303
304        assert_eq!(builder.config.focus_right, Some(Id::from(("next", 0u32)).id));
305        assert_eq!(builder.config.focus_left, Some(Id::from(("prev", 0u32)).id));
306        assert_eq!(builder.config.focus_up, Some(Id::from(("above", 0u32)).id));
307        assert_eq!(builder.config.focus_down, Some(Id::from(("below", 0u32)).id));
308    }
309
310    #[test]
311    fn builder_slider_properties() {
312        let mut builder = AccessibilityBuilder::new();
313        builder
314            .role(AccessibilityRole::Slider)
315            .label("Volume")
316            .description("Adjusts the master volume from 0 to 100")
317            .value("75")
318            .value_min(0.0)
319            .value_max(100.0);
320
321        assert_eq!(builder.config.role, AccessibilityRole::Slider);
322        assert_eq!(builder.config.label, "Volume");
323        assert_eq!(builder.config.value, "75");
324        assert_eq!(builder.config.value_min, Some(0.0));
325        assert_eq!(builder.config.value_max, Some(100.0));
326    }
327
328    #[test]
329    fn builder_ring_styling() {
330        let mut builder = AccessibilityBuilder::new();
331        builder
332            .focusable()
333            .ring_color(Color::rgb(0.0, 120.0, 255.0))
334            .ring_width(3);
335
336        assert!(builder.config.show_ring);
337        assert_eq!(builder.config.ring_color, Some(Color::rgb(0.0, 120.0, 255.0)));
338        assert_eq!(builder.config.ring_width, Some(3));
339    }
340
341    #[test]
342    fn ring_color_accepts_into() {
343        let mut builder = AccessibilityBuilder::new();
344        builder.ring_color((0u8, 120u8, 255u8));
345        assert_eq!(builder.config.ring_color, Some(Color::rgb(0.0, 120.0, 255.0)));
346    }
347
348    #[test]
349    fn ring_defaults_are_none() {
350        let config = AccessibilityConfig::new();
351        assert!(config.show_ring);
352        assert!(config.ring_color.is_none());
353        assert!(config.ring_width.is_none());
354    }
355}