StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 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 | |
| 48 | use crate::Action; |
| 49 | use pathfinder_geometry::rect::RectF; |
| 50 | use serde::{Deserialize, Serialize}; |
| 51 | |
| 52 | #[derive(Debug, Clone)] |
| 53 | /// Main structure describing the content VoiceOver (or other screen reading software) will receive. |
| 54 | pub 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 | )] |
| 87 | pub 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! |
| 105 | fn 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 | |
| 148 | impl 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)] |
| 205 | pub 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 | |
| 224 | impl 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)] |
| 249 | pub enum ActionAccessibilityContent { |
| 250 | #[default] |
| 251 | Empty, |
| 252 | Custom(AccessibilityContent), |
| 253 | CustomFn(fn(&dyn Action) -> AccessibilityContent), |
| 254 | } |
| 255 | |
| 256 | impl ActionAccessibilityContent { |
| 257 | pub fn from_debug() -> Self { |
| 258 | Self::CustomFn(|action| { |
| 259 | AccessibilityContent::new_without_help(format!("{action:?}."), WarpA11yRole::UserAction) |
| 260 | }) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | impl 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 |