Skip to main content

oxiui_accessibility/
focus.rs

1//! Focus indicator visual properties for OxiUI accessibility.
2//!
3//! Provides [`FocusRing`] (the visual spec for the focus outline) and
4//! [`FocusIndicator`] (tracks which node currently has focus and what ring
5//! spec to use when rendering it).  Renderers consume these types to draw
6//! the platform-appropriate focus ring without knowing the full a11y tree.
7
8use accesskit::NodeId;
9
10// ── Focus ring spec ───────────────────────────────────────────────────────────
11
12/// Visual properties for a focus ring, consumed by renderers.
13///
14/// Describes the outline drawn around the currently-focused widget.
15/// All measurements are in logical pixels.
16#[derive(Debug, Clone, PartialEq)]
17pub struct FocusRing {
18    /// Colour of the ring in RGBA byte order `[r, g, b, a]`.
19    pub color: [u8; 4],
20    /// Stroke width in logical pixels.
21    pub width: f32,
22    /// Outset distance from the widget's bounding box in logical pixels.
23    pub offset: f32,
24    /// Corner radius of the ring in logical pixels (`0.0` = sharp corners).
25    pub radius: f32,
26}
27
28impl Default for FocusRing {
29    fn default() -> Self {
30        Self {
31            // Windows system-highlight blue (#0078D7), fully opaque.
32            color: [0, 120, 215, 255],
33            width: 2.0,
34            offset: 2.0,
35            radius: 3.0,
36        }
37    }
38}
39
40impl FocusRing {
41    /// Compute the bounding rectangle for the ring given the widget's bounding
42    /// box `(x, y, width, height)` in logical pixels.
43    ///
44    /// Returns `(rx, ry, rw, rh)` where the ring is outset by `self.offset` on
45    /// all sides and the stroke of `self.width` is applied further outward.
46    /// This is the rectangle that a renderer should stroke / outline.
47    ///
48    /// # Note for renderers
49    ///
50    /// The returned rectangle is the *outer* boundary of the ring stroke.
51    /// Renderers should stroke the rectangle inward by `self.width / 2.0` to
52    /// position the stroke centrally on the boundary.
53    ///
54    /// # Example
55    ///
56    /// ```rust
57    /// use oxiui_accessibility::FocusRing;
58    ///
59    /// let ring = FocusRing { width: 2.0, offset: 2.0, ..Default::default() };
60    /// let (rx, ry, rw, rh) = ring.ring_rect(10.0, 20.0, 100.0, 30.0);
61    /// // outset by offset (2) + half width (1) on each side
62    /// assert_eq!(rx, 10.0 - 2.0 - 1.0);
63    /// assert_eq!(ry, 20.0 - 2.0 - 1.0);
64    /// assert_eq!(rw, 100.0 + (2.0 + 1.0) * 2.0);
65    /// assert_eq!(rh, 30.0 + (2.0 + 1.0) * 2.0);
66    /// ```
67    pub fn ring_rect(&self, x: f32, y: f32, width: f32, height: f32) -> (f32, f32, f32, f32) {
68        let grow = self.offset + self.width / 2.0;
69        (x - grow, y - grow, width + grow * 2.0, height + grow * 2.0)
70    }
71
72    /// Returns `true` when the ring should be rendered (i.e. it has a non-zero
73    /// stroke width and a non-fully-transparent colour).
74    ///
75    /// Renderers may skip drawing the ring when this returns `false`.
76    ///
77    /// # Example
78    ///
79    /// ```rust
80    /// use oxiui_accessibility::FocusRing;
81    ///
82    /// let visible = FocusRing::default();
83    /// assert!(visible.is_visible());
84    ///
85    /// let invisible = FocusRing { color: [0, 0, 0, 0], ..Default::default() };
86    /// assert!(!invisible.is_visible());
87    /// ```
88    pub fn is_visible(&self) -> bool {
89        self.width > 0.0 && self.color[3] > 0
90    }
91}
92
93// ── Focus indicator ───────────────────────────────────────────────────────────
94
95/// Tracks which node currently holds focus and the visual ring spec to use.
96///
97/// Renderers query [`FocusIndicator::focused_node`] to know which widget is
98/// focused and [`FocusIndicator::ring`] to know how to draw its outline.
99///
100/// This is intentionally decoupled from [`crate::tree::A11yTree`]'s focus
101/// field (which drives the AccessKit `TreeUpdate::focus` field for screen
102/// readers).  Both should be kept in sync, but keeping them separate allows
103/// the render layer to style the ring independently of the a11y adapter.
104pub struct FocusIndicator {
105    focused_node: Option<NodeId>,
106    ring: FocusRing,
107}
108
109impl Default for FocusIndicator {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl FocusIndicator {
116    /// Create a new indicator with no focused node and the default ring spec.
117    pub fn new() -> Self {
118        Self {
119            focused_node: None,
120            ring: FocusRing::default(),
121        }
122    }
123
124    /// Set (or clear) the currently focused node.
125    ///
126    /// Pass `None` to clear the focus — no ring will be rendered.
127    pub fn set_focus(&mut self, id: Option<NodeId>) {
128        self.focused_node = id;
129    }
130
131    /// Return the [`NodeId`] of the currently focused node, if any.
132    pub fn focused_node(&self) -> Option<NodeId> {
133        self.focused_node
134    }
135
136    /// Return a shared reference to the current [`FocusRing`] spec.
137    pub fn ring(&self) -> &FocusRing {
138        &self.ring
139    }
140
141    /// Replace the ring spec with a custom one (builder-style).
142    pub fn with_ring(mut self, ring: FocusRing) -> Self {
143        self.ring = ring;
144        self
145    }
146}