facett-core 0.1.5

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,
}

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 }
    }
    /// 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
    }

    /// 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.
pub fn stable_id(base: Id, key: impl std::hash::Hash) -> 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,
    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 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"));
    }
}