facett-core/src/focus.rs
//! **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
}
/// Register this group in the navigation graph ([`crate::navgraph`]): a `Group`
/// whose declared children are its fields, in field order. Call it after layout
/// (the field rects are filled). Dev mode only; a no-op otherwise.
pub fn nav_register(&self, ui: &egui::Ui) {
crate::navgraph::focus_group(ui, 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, curve: crate::effects::Curve::Linear }, 0.0), 1.0);
}
}