Skip to main content

facett_core/
a11y.rs

1//! **facett-core::a11y** — the FC-4 enabler. One helper that makes a
2//! *custom-painted* element appear in the AccessKit tree.
3//!
4//! egui's immediate-mode painter (`ui.painter*`) draws pixels but emits **no**
5//! accessibility node — a robot driver / screen reader / `egui_kittest`
6//! `get_by_label` cannot see anything drawn that way. The contract's FC-4 says
7//! *every interactive or value-bearing painted element MUST populate an
8//! AccessKit node* (role + stable label + value + actions). FC-5 says every
9//! addressable element MUST carry a stable, unique [`egui::Id`] that is **not**
10//! derived from a loop index / timestamp / RNG.
11//!
12//! This module gives both in one call:
13//!
14//! ```ignore
15//! let hit = facett_core::a11y::node(ui, base_id, "node-7", egui::Sense::click(),
16//!     rect, facett_core::a11y::Semantics::button("worker · running · 42 rows"));
17//! if hit.clicked() { /* … */ }
18//! ```
19//!
20//! The painter keeps drawing the visual; `node` allocates a hit-testable
21//! [`egui::Response`] over the same `rect` keyed on a **stable domain key**
22//! (so reorder/insert/delete preserves identity) and attaches a
23//! [`egui::WidgetInfo`] so the element rides into the AccessKit tree as a
24//! labelled, value-carrying, addressable node.
25
26use egui::{Id, Rect, Response, Sense, Ui, WidgetInfo, WidgetType};
27
28/// The semantic payload attached to a painted element — what role it plays, the
29/// human/agent-readable label, an optional numeric value, and whether it is the
30/// current selection. This is the data a screen reader announces and a robot
31/// driver / `get_by_label` retrieves.
32#[derive(Clone, Debug, PartialEq)]
33pub struct Semantics {
34    /// AccessKit role (egui maps `WidgetType` → `accesskit::Role`).
35    pub typ: WidgetType,
36    /// Stable, human-readable label. MUST be unique within a surface (FC-5).
37    pub label: String,
38    /// Optional numeric value (a grid cell number, a bar height, a node count,
39    /// a yaw angle). Becomes the AccessKit `numeric_value`.
40    pub value: Option<f64>,
41    /// Whether this element is the current selection (a selected cell/row/node).
42    pub selected: Option<bool>,
43    /// Whether the element is enabled/interactive.
44    pub enabled: bool,
45}
46
47impl Semantics {
48    /// A labelled element of an arbitrary role (no value, no selection).
49    pub fn new(typ: WidgetType, label: impl Into<String>) -> Self {
50        Self { typ, label: label.into(), value: None, selected: None, enabled: true }
51    }
52    /// A clickable button (e.g. a painted graph/DAG node, a chip, a tile).
53    pub fn button(label: impl Into<String>) -> Self {
54        Self::new(WidgetType::Button, label)
55    }
56    /// A value-bearing image/region summary (e.g. a whole map/plot pane).
57    pub fn image(label: impl Into<String>) -> Self {
58        Self::new(WidgetType::Image, label)
59    }
60    /// A selectable list/row item (file row, command-palette row, menu item).
61    pub fn list_item(label: impl Into<String>, selected: bool) -> Self {
62        Self { selected: Some(selected), ..Self::new(WidgetType::SelectableLabel, label) }
63    }
64    /// Attach a numeric value (chainable). Carried as AccessKit `numeric_value`,
65    /// so `get_by_label(..).numeric_value()` reads it back exactly.
66    pub fn value(mut self, v: f64) -> Self {
67        self.value = Some(v);
68        self
69    }
70    /// Mark this element as selected / not (chainable).
71    pub fn selected(mut self, sel: bool) -> Self {
72        self.selected = Some(sel);
73        self
74    }
75    /// Mark this element enabled / disabled (chainable).
76    pub fn enabled(mut self, en: bool) -> Self {
77        self.enabled = en;
78        self
79    }
80
81    /// Build the egui [`WidgetInfo`] this semantics maps to.
82    pub fn widget_info(&self) -> WidgetInfo {
83        let mut info = match self.selected {
84            Some(sel) => WidgetInfo::selected(self.typ, self.enabled, sel, self.label.clone()),
85            None => WidgetInfo::labeled(self.typ, self.enabled, self.label.clone()),
86        };
87        info.value = self.value;
88        info
89    }
90}
91
92/// Derive a **stable** [`egui::Id`] for an addressable painted element from a
93/// component-base id and a *stable domain key* (a node name, a row's domain id,
94/// a `lon,lat` — NOT a loop index). Satisfies FC-5.
95pub fn stable_id(base: Id, key: impl std::hash::Hash) -> Id {
96    base.with(key)
97}
98
99/// **The FC-4/FC-5 helper.** Allocate a hit-testable [`Response`] over a
100/// painted element's `rect`, keyed on a stable domain `key`, and attach the
101/// AccessKit semantics. The painter still draws the visual; this only adds the
102/// accessibility node + a stable, sense-able interaction target on top.
103///
104/// Returns the [`Response`] so the caller can read `.clicked()`, `.hovered()`,
105/// etc. — the same as any standard widget.
106pub fn node(
107    ui: &mut Ui,
108    base: Id,
109    key: impl std::hash::Hash,
110    sense: Sense,
111    rect: Rect,
112    sem: Semantics,
113) -> Response {
114    let id = stable_id(base, key);
115    let response = ui.interact(rect, id, sense);
116    response.widget_info(|| sem.widget_info());
117    response
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn semantics_button_carries_label_and_value() {
126        let s = Semantics::button("worker · running").value(42.0);
127        let info = s.widget_info();
128        assert_eq!(info.typ, WidgetType::Button);
129        assert_eq!(info.label.as_deref(), Some("worker · running"));
130        assert_eq!(info.value, Some(42.0));
131        assert_eq!(info.selected, None);
132    }
133
134    #[test]
135    fn semantics_list_item_carries_selection() {
136        let s = Semantics::list_item("file.txt", true);
137        let info = s.widget_info();
138        assert_eq!(info.typ, WidgetType::SelectableLabel);
139        assert_eq!(info.selected, Some(true));
140        assert_eq!(info.label.as_deref(), Some("file.txt"));
141    }
142
143    #[test]
144    fn stable_id_is_key_derived_not_index() {
145        let base = Id::new("comp");
146        // Same key → same id regardless of call site/order (FC-5).
147        assert_eq!(stable_id(base, "node-a"), stable_id(base, "node-a"));
148        // Different keys → different ids.
149        assert_ne!(stable_id(base, "node-a"), stable_id(base, "node-b"));
150    }
151}