facett-core/src/a11y.rs
//! **facett-core::a11y** — the FC-4 enabler. One helper that makes a
//! *custom-painted* element appear in the AccessKit tree.
//!
//! egui's immediate-mode painter (`ui.painter*`) draws pixels but emits **no**
//! accessibility node — a robot driver / screen reader / `egui_kittest`
//! `get_by_label` cannot see anything drawn that way. The contract's FC-4 says
//! *every interactive or value-bearing painted element MUST populate an
//! AccessKit node* (role + stable label + value + actions). FC-5 says every
//! addressable element MUST carry a stable, unique [`egui::Id`] that is **not**
//! derived from a loop index / timestamp / RNG.
//!
//! This module gives both in one call:
//!
//! ```ignore
//! let hit = facett_core::a11y::node(ui, base_id, "node-7", egui::Sense::click(),
//! rect, facett_core::a11y::Semantics::button("worker · running · 42 rows"));
//! if hit.clicked() { /* … */ }
//! ```
//!
//! The painter keeps drawing the visual; `node` allocates a hit-testable
//! [`egui::Response`] over the same `rect` keyed on a **stable domain key**
//! (so reorder/insert/delete preserves identity) and attaches a
//! [`egui::WidgetInfo`] so the element rides into the AccessKit tree as a
//! labelled, value-carrying, addressable node.
use egui::{Id, Rect, Response, Sense, Ui, WidgetInfo, WidgetType};
/// The semantic payload attached to a painted element — what role it plays, the
/// human/agent-readable label, an optional numeric value, and whether it is the
/// current selection. This is the data a screen reader announces and a robot
/// driver / `get_by_label` retrieves.
#[derive(Clone, Debug, PartialEq)]
pub struct Semantics {
/// AccessKit role (egui maps `WidgetType` → `accesskit::Role`).
pub typ: WidgetType,
/// Stable, human-readable label. MUST be unique within a surface (FC-5).
pub label: String,
/// Optional numeric value (a grid cell number, a bar height, a node count,
/// a yaw angle). Becomes the AccessKit `numeric_value`.
pub value: Option<f64>,
/// Whether this element is the current selection (a selected cell/row/node).
pub selected: Option<bool>,
/// Whether the element is enabled/interactive.
pub enabled: bool,
/// The **STRUCTURAL severity** this atom carries (RESOLVED decision (a)). The
/// atom-level source a pane folds (via [`crate::worst_severity`]) into its
/// [`Facet::severity`](crate::Facet::severity) — the signal the Robot-UI HARD
/// GATE reads instead of scanning the label text. Defaults to
/// [`Severity::Info`](crate::Severity::Info); set it with
/// [`Semantics::severity`] (or the [`Semantics::error`]/[`Semantics::warning`]
/// constructors) on an atom that rendered a failure/warning.
pub severity: crate::Severity,
/// The optional STABLE **error code** (`facet-<component>-<n>`, see
/// [`crate::errcode`]) this atom carries. Additive (defaults `None`); set it with
/// [`Semantics::error_code`] so a headless robot reads the CODE off the AccessKit
/// tree, not just the label string.
pub code: Option<&'static str>,
}
impl Semantics {
/// A labelled element of an arbitrary role (no value, no selection).
pub fn new(typ: WidgetType, label: impl Into<String>) -> Self {
Self {
typ,
label: label.into(),
value: None,
selected: None,
enabled: true,
severity: crate::Severity::Info,
code: None,
}
}
/// Attach a stable [`errcode`](crate::errcode) code (`facet-<component>-<n>`) to
/// this atom (chainable) — the code a headless robot reads off the tree.
pub fn error_code(mut self, code: &'static str) -> Self {
self.code = Some(code);
self
}
/// A clickable button (e.g. a painted graph/DAG node, a chip, a tile).
pub fn button(label: impl Into<String>) -> Self {
Self::new(WidgetType::Button, label)
}
/// A value-bearing image/region summary (e.g. a whole map/plot pane).
pub fn image(label: impl Into<String>) -> Self {
Self::new(WidgetType::Image, label)
}
/// A selectable list/row item (file row, command-palette row, menu item).
pub fn list_item(label: impl Into<String>, selected: bool) -> Self {
Self { selected: Some(selected), ..Self::new(WidgetType::SelectableLabel, label) }
}
/// Attach a numeric value (chainable). Carried as AccessKit `numeric_value`,
/// so `get_by_label(..).numeric_value()` reads it back exactly.
pub fn value(mut self, v: f64) -> Self {
self.value = Some(v);
self
}
/// Mark this element as selected / not (chainable).
pub fn selected(mut self, sel: bool) -> Self {
self.selected = Some(sel);
self
}
/// Mark this element enabled / disabled (chainable).
pub fn enabled(mut self, en: bool) -> Self {
self.enabled = en;
self
}
/// Attach a [`Severity`](crate::Severity) to this atom (chainable) — the
/// STRUCTURAL error signal the Robot-UI gate reads (decision (a)).
pub fn severity(mut self, sev: crate::Severity) -> Self {
self.severity = sev;
self
}
/// A labelled atom that rendered an **error** — [`Severity::Error`](crate::Severity::Error).
/// A pane folding this atom goes RED and FAILS the Robot-UI gate.
pub fn error(label: impl Into<String>) -> Self {
Self::new(WidgetType::Label, label).severity(crate::Severity::Error)
}
/// A labelled atom carrying a **warning** — [`Severity::Warning`](crate::Severity::Warning).
/// Recorded (visible) but does not fail the gate on its own.
pub fn warning(label: impl Into<String>) -> Self {
Self::new(WidgetType::Label, label).severity(crate::Severity::Warning)
}
/// Build the egui [`WidgetInfo`] this semantics maps to.
pub fn widget_info(&self) -> WidgetInfo {
let mut info = match self.selected {
Some(sel) => WidgetInfo::selected(self.typ, self.enabled, sel, self.label.clone()),
None => WidgetInfo::labeled(self.typ, self.enabled, self.label.clone()),
};
info.value = self.value;
info
}
}
/// Derive a **stable** [`egui::Id`] for an addressable painted element from a
/// component-base id and a *stable domain key* (a node name, a row's domain id,
/// a `lon,lat` — NOT a loop index). Satisfies FC-5.
// egui 0.35 requires `Debug` alongside `Hash` for an id salt (`AsIdSalt`), so that a
// clashing id can be REPORTED with the salt that produced it instead of just a number.
// That is the same argument this function's own doc makes — the salt must be a stable
// domain key, and a diagnosable one is strictly better.
pub fn stable_id(base: Id, key: impl std::hash::Hash + std::fmt::Debug) -> Id {
base.with(key)
}
/// **The FC-4/FC-5 helper.** Allocate a hit-testable [`Response`] over a
/// painted element's `rect`, keyed on a stable domain `key`, and attach the
/// AccessKit semantics. The painter still draws the visual; this only adds the
/// accessibility node + a stable, sense-able interaction target on top.
///
/// Returns the [`Response`] so the caller can read `.clicked()`, `.hovered()`,
/// etc. — the same as any standard widget.
pub fn node(
ui: &mut Ui,
base: Id,
key: impl std::hash::Hash + std::fmt::Debug,
sense: Sense,
rect: Rect,
sem: Semantics,
) -> Response {
let id = stable_id(base, &key);
let response = ui.interact(rect, id, sense);
response.widget_info(|| sem.widget_info());
// Every FC-4 atom is a `Field` of the navigation graph (dev mode only; one atomic
// load when the overlay is off).
crate::navgraph::atom(ui, &key, &sem.label, &response);
response
}
/// [`node`] without the navgraph registration — for an overlay's own atoms (the dev-id
/// chip), which are not part of the application's navigation.
pub(crate) fn node_quiet(
ui: &mut Ui,
base: Id,
key: impl std::hash::Hash + std::fmt::Debug,
sense: Sense,
rect: Rect,
sem: Semantics,
) -> Response {
let id = stable_id(base, key);
let response = ui.interact(rect, id, sense);
response.widget_info(|| sem.widget_info());
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semantics_button_carries_label_and_value() {
let s = Semantics::button("worker · running").value(42.0);
let info = s.widget_info();
assert_eq!(info.typ, WidgetType::Button);
assert_eq!(info.label.as_deref(), Some("worker · running"));
assert_eq!(info.value, Some(42.0));
assert_eq!(info.selected, None);
}
#[test]
fn semantics_list_item_carries_selection() {
let s = Semantics::list_item("file.txt", true);
let info = s.widget_info();
assert_eq!(info.typ, WidgetType::SelectableLabel);
assert_eq!(info.selected, Some(true));
assert_eq!(info.label.as_deref(), Some("file.txt"));
}
#[test]
fn semantics_carry_structural_severity() {
use crate::Severity;
// Default atom is the green floor.
assert_eq!(Semantics::button("ok").severity, Severity::Info);
// The error/warning constructors + the chainable setter carry it.
assert_eq!(Semantics::error("scan failed").severity, Severity::Error);
assert_eq!(Semantics::warning("partial load").severity, Severity::Warning);
assert_eq!(
Semantics::image("map").severity(Severity::Error).severity,
Severity::Error
);
// A pane folds its atoms' severities into the worst one (the gate signal).
let atoms = [
Semantics::button("run"),
Semantics::error("open warehouse failed"),
Semantics::list_item("row", false),
];
assert_eq!(
crate::worst_severity(atoms.iter().map(|s| s.severity)),
Severity::Error,
"one error atom makes the pane red"
);
}
#[test]
fn stable_id_is_key_derived_not_index() {
let base = Id::new("comp");
// Same key → same id regardless of call site/order (FC-5).
assert_eq!(stable_id(base, "node-a"), stable_id(base, "node-a"));
// Different keys → different ids.
assert_ne!(stable_id(base, "node-a"), stable_id(base, "node-b"));
}
}