oxiui_accessibility/props.rs
1//! Property primitives for [`crate::tree::A11yNode`].
2//!
3//! This module defines the small value types that decorate an a11y node beyond
4//! its role and label: live-region politeness, three-state toggles, text caret
5//! / selection coordinates, and the public `From` mappings to the corresponding
6//! AccessKit types. Keeping these in a dedicated module lets `tree.rs` focus on
7//! the node-graph plumbing and lets the builder / diff modules import from a
8//! single small surface.
9//!
10//! All conversions are *infallible*: the property types are designed so that
11//! every valid OxiUI value has a faithful AccessKit representation. This
12//! contract is what allows the tree builder to avoid `unwrap`/`panic` while
13//! still emitting fully-typed AccessKit nodes.
14
15use accesskit::{Live, NodeId, Toggled};
16
17// ── Live region politeness ───────────────────────────────────────────────────
18
19/// Live-region politeness for screen-reader announcements.
20///
21/// Mirrors the W3C ARIA `aria-live` values:
22///
23/// * [`LiveSetting::Off`] — content updates are not announced.
24/// * [`LiveSetting::Polite`] — wait for the screen reader to finish its current
25/// utterance, then announce.
26/// * [`LiveSetting::Assertive`] — interrupt the current utterance and announce
27/// immediately. Reserve for urgent feedback (errors, time-critical alerts).
28///
29/// The variant ordering matches AccessKit's [`accesskit::Live`] enum so that
30/// `From` is a trivial 1:1 mapping.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32#[repr(u8)]
33pub enum LiveSetting {
34 /// Updates to this node are not announced.
35 #[default]
36 Off,
37 /// Updates are queued behind the screen reader's current utterance.
38 Polite,
39 /// Updates interrupt the current utterance.
40 Assertive,
41}
42
43impl From<LiveSetting> for Live {
44 #[inline]
45 fn from(value: LiveSetting) -> Self {
46 match value {
47 LiveSetting::Off => Live::Off,
48 LiveSetting::Polite => Live::Polite,
49 LiveSetting::Assertive => Live::Assertive,
50 }
51 }
52}
53
54// ── Three-state toggle (checked / mixed / unchecked) ─────────────────────────
55
56/// Three-state toggle for `Checkbox` / `MenuItemCheckBox` / `Tab` selection.
57///
58/// `bool` cannot encode the *mixed* state needed for tri-state checkboxes
59/// (parent rows in a tree view, "select all" toggles, etc.); this enum can.
60///
61/// Convert from a plain `bool` via `From`:
62///
63/// ```rust
64/// use oxiui_accessibility::props::Toggled3;
65/// assert_eq!(Toggled3::from(true), Toggled3::True);
66/// assert_eq!(Toggled3::from(false), Toggled3::False);
67/// ```
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
69#[repr(u8)]
70pub enum Toggled3 {
71 /// The control is in the *off* / *unchecked* state.
72 #[default]
73 False,
74 /// The control is in the *on* / *checked* state.
75 True,
76 /// The control is in the *indeterminate* / *mixed* state (tri-state).
77 Mixed,
78}
79
80impl From<bool> for Toggled3 {
81 #[inline]
82 fn from(b: bool) -> Self {
83 if b {
84 Toggled3::True
85 } else {
86 Toggled3::False
87 }
88 }
89}
90
91impl From<Toggled3> for Toggled {
92 #[inline]
93 fn from(value: Toggled3) -> Self {
94 match value {
95 Toggled3::False => Toggled::False,
96 Toggled3::True => Toggled::True,
97 Toggled3::Mixed => Toggled::Mixed,
98 }
99 }
100}
101
102/// Three-state checked state for checkboxes.
103///
104/// This is a type alias to [`Toggled3`] so that the API is semantically
105/// self-documenting where the concept of "checked vs toggled" applies.
106pub type CheckedState = Toggled3;
107
108/// Conversion from a `&CheckedState` reference to [`Toggled3`] (identity copy).
109impl From<&CheckedState> for Toggled3 {
110 #[inline]
111 fn from(c: &CheckedState) -> Toggled3 {
112 *c
113 }
114}
115
116// ── Text caret / selection coordinates ───────────────────────────────────────
117
118/// Byte-offset describing the text caret / a text selection on an editable
119/// node.
120///
121/// Offsets are **byte** indices into the UTF-8 representation of the
122/// [`crate::tree::A11yNode::text_content`] string. The tree builder
123/// translates them into AccessKit's [`accesskit::TextSelection`] /
124/// [`accesskit::TextPosition`] coordinates by synthesising a child
125/// [`accesskit::Role::TextRun`] node carrying the text's character-length
126/// table.
127///
128/// For a pure caret (no selection), set [`TextCaret::start`] and
129/// [`TextCaret::end`] to the same value.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
131pub struct TextCaret {
132 /// Byte offset of the selection anchor (does not move while extending).
133 pub start: usize,
134 /// Byte offset of the selection focus (moves while extending).
135 pub end: usize,
136}
137
138impl TextCaret {
139 /// Construct a degenerate selection at byte offset `pos` (i.e. a caret).
140 #[inline]
141 pub const fn caret(pos: usize) -> Self {
142 Self {
143 start: pos,
144 end: pos,
145 }
146 }
147
148 /// Construct a selection running between two byte offsets.
149 ///
150 /// The two offsets may appear in either order; the type stores them as
151 /// supplied so that callers can preserve the directional anchor/focus
152 /// distinction.
153 #[inline]
154 pub const fn range(start: usize, end: usize) -> Self {
155 Self { start, end }
156 }
157
158 /// Lower bound of the selected range, regardless of direction.
159 #[inline]
160 pub fn lo(&self) -> usize {
161 core::cmp::min(self.start, self.end)
162 }
163
164 /// Upper bound of the selected range, regardless of direction.
165 #[inline]
166 pub fn hi(&self) -> usize {
167 core::cmp::max(self.start, self.end)
168 }
169
170 /// `true` if this caret has no selected range (start == end).
171 #[inline]
172 pub fn is_caret(&self) -> bool {
173 self.start == self.end
174 }
175}
176
177/// A text selection expressed as byte offsets (anchor/focus).
178///
179/// Semantically equivalent to [`TextCaret`] but with different field names to
180/// match the spec's `A11yNodeProps::text_selection` field.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
182pub struct TextSelection {
183 /// Byte offset of the anchor (the fixed end).
184 pub anchor: usize,
185 /// Byte offset of the focus (the moving end / caret position).
186 pub focus: usize,
187}
188
189impl TextSelection {
190 /// A collapsed caret at `pos`.
191 #[inline]
192 pub const fn caret(pos: usize) -> Self {
193 Self {
194 anchor: pos,
195 focus: pos,
196 }
197 }
198
199 /// `true` if anchor == focus (no selection range, just a caret).
200 #[inline]
201 pub fn is_caret(&self) -> bool {
202 self.anchor == self.focus
203 }
204}
205
206// ── Text-run child segment ───────────────────────────────────────────────────
207
208/// A synthesized text-run segment for caret/selection exposure.
209///
210/// Text nodes that carry a [`TextSelection`] are split into up to three
211/// `TextRunChild` segments by [`crate::tree::synthesize_text_run_children`]:
212/// the text *before* the selection, the *selected* span, and the text *after*
213/// the selection. Nodes with no selection produce a single segment for the
214/// whole text.
215///
216/// Offsets are expressed both as byte indices (for slicing) and as char
217/// indices (for AccessKit's `TextPosition.character_index`).
218#[derive(Debug, Clone, Default)]
219pub struct TextRunChild {
220 /// The UTF-8 text content of this segment.
221 pub text: String,
222 /// 0-based character index of the first character in this segment.
223 pub char_offset: usize,
224 /// 0-based byte index of the first byte in this segment.
225 pub byte_offset: usize,
226 /// `true` if this segment falls within the selection range.
227 pub is_selected: bool,
228}
229
230// ── Rich property bag ────────────────────────────────────────────────────────
231
232/// Rich property bag attached to every [`crate::tree::A11yNode`].
233///
234/// All fields are optional / defaulted so that callers only set what they need.
235/// The tree builder reads these fields and forwards them to the corresponding
236/// AccessKit setters.
237#[derive(Debug, Clone, Default)]
238pub struct A11yNodeProps {
239 // ── Text / description ───────────────────────────────────────────────────
240 /// Longer description of the widget (ARIA `aria-describedby`-equivalent text).
241 pub description: Option<String>,
242 /// Placeholder text for empty text inputs.
243 pub placeholder: Option<String>,
244 /// Keyboard shortcut that activates this widget (e.g. `"Ctrl+S"`).
245 pub key_shortcut: Option<String>,
246
247 // ── State ────────────────────────────────────────────────────────────────
248 /// `true` if the widget is non-interactive.
249 pub disabled: bool,
250 /// Expanded state: `Some(true)` = expanded, `Some(false)` = collapsed,
251 /// `None` = not expandable.
252 pub expanded: Option<bool>,
253 /// Selected state: `Some(true/false)` = selectable, `None` = not selectable.
254 pub selected: Option<bool>,
255 /// Checked / toggle state; `None` = not checkable.
256 pub checked: Option<CheckedState>,
257
258 // ── Range values ─────────────────────────────────────────────────────────
259 /// Current numeric value (sliders, progress bars, spinners).
260 pub value_now: Option<f64>,
261 /// Minimum allowed numeric value.
262 pub value_min: Option<f64>,
263 /// Maximum allowed numeric value.
264 pub value_max: Option<f64>,
265 /// Step increment for the numeric value.
266 pub value_step: Option<f64>,
267
268 // ── Text content + cursor ─────────────────────────────────────────────────
269 /// Text content / string value of the node.
270 pub text_value: Option<String>,
271 /// Text selection (anchor + focus byte offsets).
272 pub text_selection: Option<TextSelection>,
273
274 // ── Relationships ─────────────────────────────────────────────────────────
275 /// Nodes that label this node (ARIA `aria-labelledby`).
276 pub labelled_by: Vec<NodeId>,
277 /// Nodes that describe this node (ARIA `aria-describedby`).
278 pub described_by: Vec<NodeId>,
279 /// Nodes that this node controls (ARIA `aria-controls`).
280 pub controlled_by: Vec<NodeId>,
281 /// Nodes that this node logically owns but that are not DOM descendants.
282 pub owns: Vec<NodeId>,
283
284 // ── Text run children ─────────────────────────────────────────────────────
285 /// Synthesized text-run child segments for caret/selection exposure.
286 ///
287 /// Populated by [`crate::tree::synthesize_text_run_children`] for text
288 /// nodes that carry a [`TextSelection`]. Empty by default.
289 pub text_run_children: Vec<TextRunChild>,
290
291 // ── Keyboard navigation ───────────────────────────────────────────────────
292 /// Explicit tab index controlling keyboard-focus order.
293 ///
294 /// `None` / `Some(0)` = natural document order; `Some(n)` where `n > 0` =
295 /// explicit position (lower values receive focus first). Interpreted by
296 /// [`crate::nav::TabOrder::compute`].
297 pub tab_index: Option<u32>,
298}
299
300// ── UTF-8 character-length table ─────────────────────────────────────────────
301
302/// Build the AccessKit `character_lengths` table for `text`.
303///
304/// AccessKit requires `Role::TextRun` nodes to expose the length, in bytes, of
305/// each *grapheme* (here approximated by Unicode scalar values — i.e. each
306/// `char`). The runtime cost is `O(text.len())`.
307///
308/// Returns an empty `Vec` for empty input.
309/// Build the AccessKit `character_lengths` table for `text` (public for
310/// use by platform adapter integration layers).
311pub fn character_lengths_utf8(text: &str) -> Vec<u8> {
312 let mut out = Vec::with_capacity(text.len());
313 for ch in text.chars() {
314 // A single Unicode scalar value is at most 4 bytes in UTF-8 — fits in u8.
315 let len = ch.len_utf8() as u8;
316 out.push(len);
317 }
318 out
319}
320
321/// Clamp a byte offset to a valid char-boundary index inside `text` and return
322/// the matching *character* index suitable for [`accesskit::TextPosition`].
323///
324/// AccessKit's `character_index` is a count of entries in `character_lengths`
325/// (i.e. a 0-based char index, with `text.chars().count()` representing the
326/// end-of-line position), not a byte offset. This helper performs the
327/// translation while guarding against malformed offsets.
328/// Translate a UTF-8 byte offset to a char index (public for platform
329/// adapter integration layers).
330pub fn byte_offset_to_char_index(text: &str, byte_offset: usize) -> usize {
331 if byte_offset == 0 {
332 return 0;
333 }
334 // Walk character boundaries, counting until we reach (or pass) byte_offset.
335 let mut chars = 0usize;
336 let mut current_byte = 0usize;
337 for ch in text.chars() {
338 if current_byte >= byte_offset {
339 return chars;
340 }
341 current_byte += ch.len_utf8();
342 chars += 1;
343 }
344 // byte_offset >= text.len(): clamp to end-of-string char index.
345 chars
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn live_setting_maps_to_accesskit_live() {
354 assert!(matches!(Live::from(LiveSetting::Off), Live::Off));
355 assert!(matches!(Live::from(LiveSetting::Polite), Live::Polite));
356 assert!(matches!(
357 Live::from(LiveSetting::Assertive),
358 Live::Assertive
359 ));
360 }
361
362 #[test]
363 fn toggled3_from_bool() {
364 assert_eq!(Toggled3::from(true), Toggled3::True);
365 assert_eq!(Toggled3::from(false), Toggled3::False);
366 }
367
368 #[test]
369 fn toggled3_maps_to_accesskit_toggled() {
370 assert!(matches!(Toggled::from(Toggled3::False), Toggled::False));
371 assert!(matches!(Toggled::from(Toggled3::True), Toggled::True));
372 assert!(matches!(Toggled::from(Toggled3::Mixed), Toggled::Mixed));
373 }
374
375 #[test]
376 fn text_caret_helpers() {
377 let c = TextCaret::caret(5);
378 assert!(c.is_caret());
379 assert_eq!(c.lo(), 5);
380 assert_eq!(c.hi(), 5);
381
382 let s = TextCaret::range(2, 9);
383 assert!(!s.is_caret());
384 assert_eq!(s.lo(), 2);
385 assert_eq!(s.hi(), 9);
386
387 // Reversed anchor/focus still yields correct lo/hi.
388 let r = TextCaret::range(9, 2);
389 assert_eq!(r.lo(), 2);
390 assert_eq!(r.hi(), 9);
391 }
392
393 #[test]
394 fn text_selection_caret() {
395 let sel = TextSelection::caret(10);
396 assert!(sel.is_caret());
397 assert_eq!(sel.anchor, 10);
398 assert_eq!(sel.focus, 10);
399 }
400
401 #[test]
402 fn text_selection_range() {
403 let sel = TextSelection {
404 anchor: 3,
405 focus: 7,
406 };
407 assert!(!sel.is_caret());
408 }
409
410 #[test]
411 fn character_lengths_ascii() {
412 let v = character_lengths_utf8("hello");
413 assert_eq!(v, vec![1u8, 1, 1, 1, 1]);
414 }
415
416 #[test]
417 fn character_lengths_multibyte() {
418 // "héllo" — é is 2 bytes in UTF-8
419 let v = character_lengths_utf8("héllo");
420 assert_eq!(v, vec![1u8, 2, 1, 1, 1]);
421 }
422
423 #[test]
424 fn character_lengths_emoji() {
425 // 🦀 is 4 bytes
426 let v = character_lengths_utf8("a🦀b");
427 assert_eq!(v, vec![1u8, 4, 1]);
428 }
429
430 #[test]
431 fn character_lengths_empty() {
432 let v = character_lengths_utf8("");
433 assert!(v.is_empty());
434 }
435
436 #[test]
437 fn byte_offset_to_char_index_ascii() {
438 assert_eq!(byte_offset_to_char_index("hello", 0), 0);
439 assert_eq!(byte_offset_to_char_index("hello", 1), 1);
440 assert_eq!(byte_offset_to_char_index("hello", 5), 5);
441 // Past end clamps to end.
442 assert_eq!(byte_offset_to_char_index("hello", 999), 5);
443 }
444
445 #[test]
446 fn byte_offset_to_char_index_multibyte() {
447 // "héllo" — indexed by char: h=0, é=1, l=2, l=3, o=4, end=5
448 // Bytes: h=0 é=1..2 l=3 l=4 o=5
449 assert_eq!(byte_offset_to_char_index("héllo", 0), 0);
450 assert_eq!(byte_offset_to_char_index("héllo", 1), 1); // start of é
451 assert_eq!(byte_offset_to_char_index("héllo", 3), 2); // start of first 'l'
452 assert_eq!(byte_offset_to_char_index("héllo", 6), 5); // end
453 }
454
455 #[test]
456 fn a11y_node_props_default_is_empty() {
457 let props = A11yNodeProps::default();
458 assert!(props.description.is_none());
459 assert!(props.placeholder.is_none());
460 assert!(props.key_shortcut.is_none());
461 assert!(!props.disabled);
462 assert!(props.expanded.is_none());
463 assert!(props.selected.is_none());
464 assert!(props.checked.is_none());
465 assert!(props.value_now.is_none());
466 assert!(props.value_min.is_none());
467 assert!(props.value_max.is_none());
468 assert!(props.value_step.is_none());
469 assert!(props.labelled_by.is_empty());
470 assert!(props.described_by.is_empty());
471 assert!(props.controlled_by.is_empty());
472 assert!(props.owns.is_empty());
473 }
474}