facett-core 0.1.17

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **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());
    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"));
    }
}