Skip to main content

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}