facett-core 0.1.7

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Focus hints, pane revolver, and form groups** (§13 FOC-3/FOC-4 + FORM-*).
//! All off-by-default, themed, and **deterministic** (no ambient time): the hint
//! labels are a pure function of the focusables; the revolver transform is a pure
//! function of `(progress, target, viewport)`; form navigation is pure rect
//! geometry reusing [`nav::nearest_in_direction`](crate::nav). Built on egui focus
//! (`Memory::request_focus`/`move_focus`) at the call site.

use egui::{Pos2, Rect, emath::TSTransform};

use crate::look::{EffectsPolicy, FocusSpec, Motion};
use crate::nav::{Dir4, nearest_in_direction};

/// A which-key / Vimium-style hint: a label key painted on a focusable's badge
/// anchor (FOC-3). Pure data so a snapshot test asserts exact labels + positions.
#[derive(Clone, Debug, PartialEq)]
pub struct Hint {
    pub label: char,
    pub anchor: Pos2,
    pub target: Rect,
}

/// Assign hint labels to focusables from the theme's `hint_alphabet`, anchored at
/// each rect's top-left inset. Deterministic; wraps the alphabet if there are more
/// focusables than letters (rare; suffixing is a later nicety).
pub fn hints(focus: &FocusSpec, rects: &[Rect]) -> Vec<Hint> {
    let alpha = focus.hint_alphabet;
    rects
        .iter()
        .enumerate()
        .map(|(i, &r)| Hint {
            label: alpha[i % alpha.len()],
            anchor: r.left_top() + egui::vec2(4.0, 4.0),
            target: r,
        })
        .collect()
}

/// Resolve a pressed hint key to the focusable index it selects (FOC-3 jump).
pub fn hint_target(hints: &[Hint], key: char) -> Option<usize> {
    hints.iter().position(|h| h.label.eq_ignore_ascii_case(&key))
}

/// **Pane revolver** (FOC-4): the layer transform that brings `target` to the
/// centre/foreground of `viewport` as `progress` ∈ [0,1] eases from "in place"
/// (0) to "centred + slightly enlarged" (1). Disabled (identity) under
/// `EffectsPolicy::None`. Pure — `progress` is the injected-clock easing output.
pub fn revolver_transform(
    target: Rect,
    viewport: Rect,
    progress: f32,
    effects: EffectsPolicy,
    focus: &FocusSpec,
) -> TSTransform {
    if !focus.revolver_enabled || !effects.allows_decorative_motion() {
        return TSTransform::IDENTITY;
    }
    let t = progress.clamp(0.0, 1.0);
    // Translate the target's centre toward the viewport centre, scale up slightly,
    // scaling *about the target centre*. For a point p:
    //   f(p) = s*(p - pivot) + pivot + delta = s*p + ((1-s)*pivot + delta)
    // so this is a single TSTransform { scaling: s, translation: (1-s)*pivot+delta }.
    let delta = (viewport.center() - target.center()) * t;
    let s = 1.0 + 0.12 * t;
    let pivot = target.center().to_vec2();
    let translation = pivot * (1.0 - s) + delta;
    TSTransform::new(translation, s)
}

/// A declarative form / focus group (FORM-1): fields in order, each with an
/// optional hint key + rect (filled at layout). Single tab-stop to enter;
/// directional arrows move between fields; the hint overlay extends to fields
/// (FORM-2). Code-defined, deterministic.
#[derive(Clone, Debug, Default)]
pub struct FocusGroup {
    pub id: String,
    pub fields: Vec<FormField>,
    pub wrap: bool,
}

#[derive(Clone, Debug)]
pub struct FormField {
    pub id: String,
    pub hint: Option<char>,
    /// Filled at layout time (the field's widget rect).
    pub rect: Rect,
}

impl FocusGroup {
    pub fn new(id: impl Into<String>) -> Self {
        Self { id: id.into(), fields: Vec::new(), wrap: false }
    }
    pub fn field(mut self, id: impl Into<String>, hint: Option<char>, rect: Rect) -> Self {
        self.fields.push(FormField { id: id.into(), hint, rect });
        self
    }
    pub fn wrap(mut self, wrap: bool) -> Self {
        self.wrap = wrap;
        self
    }

    /// Index of the field with hint key `key` (FORM-2 hint jump).
    pub fn hint_target(&self, key: char) -> Option<usize> {
        self.fields.iter().position(|f| f.hint.map(|h| h.eq_ignore_ascii_case(&key)).unwrap_or(false))
    }

    /// The field a directional move from `current` lands on (FORM-2), using the
    /// shared spatial rule. Wraps to first/last when `wrap` and nothing lies that
    /// way. Returns the new field index.
    pub fn move_dir(&self, current: usize, dir: Dir4) -> Option<usize> {
        let rects: Vec<Rect> = self.fields.iter().map(|f| f.rect).collect();
        let cur = *rects.get(current)?;
        if let Some(i) = nearest_in_direction(cur, &rects, dir) {
            return Some(i);
        }
        if self.wrap && !self.fields.is_empty() {
            return Some(match dir {
                Dir4::Down | Dir4::Right => 0,
                Dir4::Up | Dir4::Left => self.fields.len() - 1,
            });
        }
        None
    }
}

/// Convenience: motion progress for an animation that started `elapsed` seconds
/// ago, eased with the theme's [`Motion`]. Deterministic given `elapsed` (the
/// injected clock supplies it), so snapshots reproduce.
pub fn motion_progress(motion: &Motion, elapsed: f32) -> f32 {
    if motion.duration <= 0.0 {
        return 1.0;
    }
    let lin = (elapsed / motion.duration).clamp(0.0, 1.0);
    crate::effects::easing::ease_in_out_cubic(lin)
}

#[cfg(test)]
mod tests {
    use egui::{pos2, vec2};

    use super::*;

    fn rects() -> Vec<Rect> {
        vec![
            Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0)),
            Rect::from_min_size(pos2(200.0, 0.0), vec2(100.0, 50.0)),
            Rect::from_min_size(pos2(0.0, 100.0), vec2(100.0, 50.0)),
        ]
    }

    #[test]
    fn hints_label_each_focusable_deterministically() {
        let focus = FocusSpec::default();
        let h = hints(&focus, &rects());
        assert_eq!(h.len(), 3);
        assert_eq!(h[0].label, 'a');
        assert_eq!(h[1].label, 's');
        assert_eq!(h[2].label, 'd');
        // Pressing 'S' jumps to focusable 1 (case-insensitive).
        assert_eq!(hint_target(&h, 'S'), Some(1));
        assert_eq!(hint_target(&h, 'z'), None);
    }

    #[test]
    fn revolver_is_identity_when_disabled_or_effects_off() {
        let target = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0));
        let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
        let off = FocusSpec { revolver_enabled: false, ..FocusSpec::default() };
        assert_eq!(revolver_transform(target, vp, 1.0, EffectsPolicy::Full, &off), TSTransform::IDENTITY);
        // Enabled but effects None (Device) → still identity (FOC-4 / §23).
        let on = FocusSpec { revolver_enabled: true, ..FocusSpec::default() };
        assert_eq!(revolver_transform(target, vp, 1.0, EffectsPolicy::None, &on), TSTransform::IDENTITY);
    }

    #[test]
    fn revolver_centres_the_target_at_full_progress() {
        let target = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0));
        let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
        let on = FocusSpec { revolver_enabled: true, ..FocusSpec::default() };
        let tf = revolver_transform(target, vp, 1.0, EffectsPolicy::Full, &on);
        // The target's centre should map onto (near) the viewport centre.
        let mapped = tf.mul_pos(target.center());
        assert!((mapped - vp.center()).length() < 1.0, "revolver brings target to viewport centre");
        // At progress 0 it's the identity (in place).
        let tf0 = revolver_transform(target, vp, 0.0, EffectsPolicy::Full, &on);
        assert_eq!(tf0, TSTransform::IDENTITY);
    }

    #[test]
    fn form_directional_move_and_hint_jump() {
        let g = FocusGroup::new("filters")
            .field("name", Some('n'), rects()[0])
            .field("date", Some('d'), rects()[1])
            .field("tags", Some('t'), rects()[2])
            .wrap(true);
        // Right from field 0 → field 1 (the one to its right).
        assert_eq!(g.move_dir(0, Dir4::Right), Some(1));
        // Down from field 0 → field 2 (below it).
        assert_eq!(g.move_dir(0, Dir4::Down), Some(2));
        // Left from field 0 wraps to the last field.
        assert_eq!(g.move_dir(0, Dir4::Left), Some(2));
        // Hint 'd' jumps to the date field.
        assert_eq!(g.hint_target('D'), Some(1));
    }

    #[test]
    fn motion_progress_is_monotonic_and_clamps() {
        let m = Motion::default();
        assert_eq!(motion_progress(&m, 0.0), 0.0);
        assert!(motion_progress(&m, m.duration * 0.5) > 0.0);
        assert_eq!(motion_progress(&m, m.duration * 2.0), 1.0);
        // Zero-duration (Device) → instantly 1.
        assert_eq!(motion_progress(&Motion { duration: 0.0, fast: 0.0 }, 0.0), 1.0);
    }
}