Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/accessibility.rs
StratoSDK / crates / strato-ui-core / src / accessibility.rs
1//! # What is accessibility?
2//! Accessibility (or a11y) is the umbrella term used to describe features that enable people with
3//! disabilities to use certain software. In our case: we focus on blind users and their day-to-day
4//! life with screen readers.
5//!
6//! ## How does a11y work in Warp?
7//! Because Warp uses its own rust UI framework (strato_ui), we don’t benefit from the built-in
8//! VoiceOver integration and objc NSAccessibility APIs. This is both good and bad for our app and
9//! the UI framework.
10//!
11//! Good parts:
12//! - We actually had to think on how to add support to the UI framework to make it easier for future
13//! app developers to not overlook a11y;
14//! - We don’t rely on complicated defaults and the cumbersome experience of analyzing all the UI
15//! elements in the app, instead we can provide a more ergonomic experience to blind users.
16//!
17//! Bad parts:
18//! - It takes time to implement the full support (for example, we still lack the ability to focus a
19//! certain UI element, like a button, and “click it” or otherwise act on it with just the keyboard);
20//! - We need to think about a11y (yeah, I mentioned it in good parts, but given that a11y is usually
21//! thirdwheeling next to AwesomeFeatures™ and BugFixes® it’s easy to ship features that are not accessible).
22//!
23//! WarpUI framework right now provides 3 ways of announcing what’s happening in the app:
24//! - Accessibility Contents for the currently focused View;
25//! - Accessibility Contents for the currently performed Action;
26//! - On-demand emitting Accessibility Contents.
27//!
28//! ## Testing for a11y
29//! We don’t have (and I don’t know if such a thing even exists) a way to automatically test a11y
30//! features. To test it then, we just need to run the app and run VoiceOver.
31//!
32//! To run it - go to your System Preferences -> Accessibility -> VoiceOver, and then click
33//! “Enable VoiceOver”. Note that it may be loud and distracting. It’s sometimes easier to turn off
34//! the sound, and check the content of the tiny rectangle that will show on your screen together
35//! with VoiceOver.
36//!
37//! ### What to look for?
38//! - Whenever a new view opens, the user will get the information about what’s happening;
39//! - The feature is keyboard accessible (a good practice would be to have it in the command palette);
40//! - Any meaningful changes to the state of feature are announced (both triggered by a user’s
41//! Action or a background Event);
42//! - The user can quit the feature and get back to the command input with keyboard (a good
43//! practice would be to keep it consistent among all the features, and quit via Escape key);
44//! - User docs mention whether the feature is accessible (on the feature’s page) and what’s the
45//! keybinding to access it;
46//! - If there’s a video/GIF in the user docs, make sure that its content is also reflected in text.
47 
48use crate::Action;
49use pathfinder_geometry::rect::RectF;
50use serde::{Deserialize, Serialize};
51 
52#[derive(Debug, Clone)]
53/// Main structure describing the content VoiceOver (or other screen reading software) will receive.
54pub struct AccessibilityContent {
55 /// The main information related to the view/action/event. Keep it short and as informative
56 /// as possible. It’s semi-equivalent to
57 /// [AccessibilityLabel](https://developer.apple.com/documentation/appkit/nsaccessibility/1534976-accessibilitylabel).
58 /// For example, a `value` for the command editor in our case is “Command Input”.
59 pub value: String,
60 /// Optional string that provides more context and information about available actions.
61 /// For example, help for the “Command Input” informs about “cmd-up” action.
62 pub help: Option<String>,
63 /// (currently unused) The rectangle that describes where the given element is on the screen.
64 /// System’s APIs then draw a frame around that element, making it super clear what object
65 /// the description is referring to.
66 /// Frame support is a work-in-progress in Warp and right now this field is omitted and not set.
67 pub frame: Option<RectF>,
68 /// The role a given element has. Note that we use our own, WarpUI-defined roles (vs those that
69 /// come from the NSAccessibility framework). The role describes the action/element/event role (
70 /// for example, when the “Command Input” is focused, it announces with a `TextareaRole`.
71 /// This is another helper field that lets the user understand what they can potentially do,
72 /// or what object is in focus.
73 pub role: WarpA11yRole,
74}
75 
76/// Verbosity level of a11y announcements. By default, all announcements include both the value
77/// and help (if provided). It can be changed per-app basis, in AppContext.
78#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
80#[cfg_attr(
81 feature = "schema_gen",
82 schemars(
83 description = "Verbosity level for screen reader announcements.",
84 rename_all = "snake_case"
85 )
86)]
87pub enum AccessibilityVerbosity {
88 /// Default verbosity level, includes help string.
89 #[default]
90 #[serde(rename = "VERBOSE")]
91 #[cfg_attr(feature = "schema_gen", schemars(rename = "verbose"))]
92 Verbose,
93 /// Concise level, only announces `value` from AccessibilityContent.
94 #[serde(rename = "CONCISE")]
95 #[cfg_attr(feature = "schema_gen", schemars(rename = "concise"))]
96 Concise,
97}
98 
99/// For single character strings, we want to announce extra information (such as
100/// capitalization). For longer/shorter strings - we just return the string itself.
101// Note: This should be localized or passed directly to voice over in a "working" format.
102// For some reason, VO currently ignores out punctuation and capital letters so we need to do it
103// manually. The Appkit Obj-C APIs don't provide support to enforce punctuation or change of pitch
104// for capital letters, so for now we're just implementing the missing pieces by hand, yay!
105fn string_announcement(s: String) -> String {
106 if s.len() != 1 {
107 return s;
108 }
109 
110 let c = s.chars().next().expect("String has exactly 1 character");
111 
112 if c.is_uppercase() {
113 return format!("capital {s}");
114 }
115 
116 if c.is_ascii_punctuation() {
117 return match c {
118 '.' => "period".to_string(),
119 '!' => "exclamation mark".to_string(),
120 '~' => "tilde".to_string(),
121 '`' => "accent".to_string(),
122 '^' => "caret".to_string(),
123 '(' => "left parenthesis".to_string(),
124 ')' => "right parenthesis".to_string(),
125 '-' => "hyphen".to_string(),
126 '_' => "underscore".to_string(),
127 '?' => "question mark".to_string(),
128 ':' => "colon".to_string(),
129 ';' => "semicolon".to_string(),
130 '"' => "double quotation mark".to_string(),
131 '\'' => "single quotation mark".to_string(),
132 '\\' => "backslash".to_string(),
133 '/' => "slash".to_string(),
134 ',' => "comma".to_string(),
135 '[' => "left bracket".to_string(),
136 ']' => "right bracket".to_string(),
137 '{' => "left brace".to_string(),
138 '}' => "right brace".to_string(),
139 '|' => "vertical line".to_string(),
140 
141 // everything else seems to have proper interpretation in voiceover
142 _ => s,
143 };
144 }
145 s
146}
147 
148impl AccessibilityContent {
149 // TODO add frame support
150 pub fn new_without_help<T>(value: T, role: WarpA11yRole) -> Self
151 where
152 T: Into<String>,
153 {
154 Self::new_internal::<T, String>(value, None, role)
155 }
156 
157 pub fn new<V, H>(value: V, help: H, role: WarpA11yRole) -> Self
158 where
159 V: Into<String>,
160 H: Into<String>,
161 {
162 Self::new_internal(value, Some(help), role)
163 }
164 
165 fn new_internal<V, H>(value: V, help: Option<H>, role: WarpA11yRole) -> Self
166 where
167 V: Into<String>,
168 H: Into<String>,
169 {
170 let value: String = value.into();
171 // Note that for values that are all whitespace, we still want to read them out, hence
172 // swapping certain whitespace characters with their "readings".
173 let value = if value.chars().all(char::is_whitespace) {
174 value
175 .replace(' ', " space ") // Note: order here is important, space should go first.
176 .replace('\t', " tab ")
177 .replace('\n', " newline ")
178 .trim()
179 .to_string()
180 } else {
181 string_announcement(value)
182 };
183 AccessibilityContent {
184 value,
185 help: help.map(|s| s.into()),
186 role,
187 frame: None,
188 }
189 }
190 
191 pub fn with_frame(mut self, frame: Option<RectF>) -> Self {
192 self.frame = frame;
193 self
194 }
195 
196 pub fn with_verbosity(mut self, verbosity: AccessibilityVerbosity) -> Self {
197 if matches!(verbosity, AccessibilityVerbosity::Concise) {
198 self.help = None;
199 }
200 self
201 }
202}
203 
204#[derive(Default, Debug, Clone, Copy)]
205pub enum WarpA11yRole {
206 ButtonRole,
207 CheckboxRole,
208 HelpRole,
209 ImageRole,
210 LinkRole,
211 ListRole,
212 MenuItemRole,
213 MenuRole,
214 PopoverRole,
215 ScrollareaRole,
216 TextRole,
217 TextareaRole,
218 TextfieldRole,
219 #[default]
220 WindowRole,
221 UserAction,
222}
223 
224impl std::fmt::Display for WarpA11yRole {
225 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
226 use WarpA11yRole::*;
227 let word = match self {
228 ButtonRole => "Button",
229 CheckboxRole => "Checkbox",
230 HelpRole => "Help",
231 ImageRole => "Image",
232 LinkRole => "Link",
233 ListRole => "List",
234 MenuItemRole => "MenuItem",
235 MenuRole => "Menu",
236 PopoverRole => "Popover",
237 ScrollareaRole => "Scrollarea",
238 TextRole => "Text",
239 TextareaRole => "Textarea",
240 TextfieldRole => "Textfield",
241 WindowRole => "Window",
242 UserAction => "Action",
243 };
244 write!(f, "{word}")
245 }
246}
247 
248#[derive(Default)]
249pub enum ActionAccessibilityContent {
250 #[default]
251 Empty,
252 Custom(AccessibilityContent),
253 CustomFn(fn(&dyn Action) -> AccessibilityContent),
254}
255 
256impl ActionAccessibilityContent {
257 pub fn from_debug() -> Self {
258 Self::CustomFn(|action| {
259 AccessibilityContent::new_without_help(format!("{action:?}."), WarpA11yRole::UserAction)
260 })
261 }
262}
263 
264impl From<Option<AccessibilityContent>> for ActionAccessibilityContent {
265 fn from(opt: Option<AccessibilityContent>) -> ActionAccessibilityContent {
266 match opt {
267 None => ActionAccessibilityContent::Empty,
268 Some(content) => ActionAccessibilityContent::Custom(content),
269 }
270 }
271}
272