Skip to main content

facett_core/
focus.rs

1//! **Focus hints, pane revolver, and form groups** (§13 FOC-3/FOC-4 + FORM-*).
2//! All off-by-default, themed, and **deterministic** (no ambient time): the hint
3//! labels are a pure function of the focusables; the revolver transform is a pure
4//! function of `(progress, target, viewport)`; form navigation is pure rect
5//! geometry reusing [`nav::nearest_in_direction`](crate::nav). Built on egui focus
6//! (`Memory::request_focus`/`move_focus`) at the call site.
7
8use egui::{Pos2, Rect, emath::TSTransform};
9
10use crate::look::{EffectsPolicy, FocusSpec, Motion};
11use crate::nav::{Dir4, nearest_in_direction};
12
13/// A which-key / Vimium-style hint: a label key painted on a focusable's badge
14/// anchor (FOC-3). Pure data so a snapshot test asserts exact labels + positions.
15#[derive(Clone, Debug, PartialEq)]
16pub struct Hint {
17    pub label: char,
18    pub anchor: Pos2,
19    pub target: Rect,
20}
21
22/// Assign hint labels to focusables from the theme's `hint_alphabet`, anchored at
23/// each rect's top-left inset. Deterministic; wraps the alphabet if there are more
24/// focusables than letters (rare; suffixing is a later nicety).
25pub fn hints(focus: &FocusSpec, rects: &[Rect]) -> Vec<Hint> {
26    let alpha = focus.hint_alphabet;
27    rects
28        .iter()
29        .enumerate()
30        .map(|(i, &r)| Hint {
31            label: alpha[i % alpha.len()],
32            anchor: r.left_top() + egui::vec2(4.0, 4.0),
33            target: r,
34        })
35        .collect()
36}
37
38/// Resolve a pressed hint key to the focusable index it selects (FOC-3 jump).
39pub fn hint_target(hints: &[Hint], key: char) -> Option<usize> {
40    hints.iter().position(|h| h.label.eq_ignore_ascii_case(&key))
41}
42
43/// **Pane revolver** (FOC-4): the layer transform that brings `target` to the
44/// centre/foreground of `viewport` as `progress` ∈ [0,1] eases from "in place"
45/// (0) to "centred + slightly enlarged" (1). Disabled (identity) under
46/// `EffectsPolicy::None`. Pure — `progress` is the injected-clock easing output.
47pub fn revolver_transform(
48    target: Rect,
49    viewport: Rect,
50    progress: f32,
51    effects: EffectsPolicy,
52    focus: &FocusSpec,
53) -> TSTransform {
54    if !focus.revolver_enabled || !effects.allows_decorative_motion() {
55        return TSTransform::IDENTITY;
56    }
57    let t = progress.clamp(0.0, 1.0);
58    // Translate the target's centre toward the viewport centre, scale up slightly,
59    // scaling *about the target centre*. For a point p:
60    //   f(p) = s*(p - pivot) + pivot + delta = s*p + ((1-s)*pivot + delta)
61    // so this is a single TSTransform { scaling: s, translation: (1-s)*pivot+delta }.
62    let delta = (viewport.center() - target.center()) * t;
63    let s = 1.0 + 0.12 * t;
64    let pivot = target.center().to_vec2();
65    let translation = pivot * (1.0 - s) + delta;
66    TSTransform::new(translation, s)
67}
68
69/// A declarative form / focus group (FORM-1): fields in order, each with an
70/// optional hint key + rect (filled at layout). Single tab-stop to enter;
71/// directional arrows move between fields; the hint overlay extends to fields
72/// (FORM-2). Code-defined, deterministic.
73#[derive(Clone, Debug, Default)]
74pub struct FocusGroup {
75    pub id: String,
76    pub fields: Vec<FormField>,
77    pub wrap: bool,
78}
79
80#[derive(Clone, Debug)]
81pub struct FormField {
82    pub id: String,
83    pub hint: Option<char>,
84    /// Filled at layout time (the field's widget rect).
85    pub rect: Rect,
86}
87
88impl FocusGroup {
89    pub fn new(id: impl Into<String>) -> Self {
90        Self { id: id.into(), fields: Vec::new(), wrap: false }
91    }
92    pub fn field(mut self, id: impl Into<String>, hint: Option<char>, rect: Rect) -> Self {
93        self.fields.push(FormField { id: id.into(), hint, rect });
94        self
95    }
96    pub fn wrap(mut self, wrap: bool) -> Self {
97        self.wrap = wrap;
98        self
99    }
100
101    /// Index of the field with hint key `key` (FORM-2 hint jump).
102    pub fn hint_target(&self, key: char) -> Option<usize> {
103        self.fields.iter().position(|f| f.hint.map(|h| h.eq_ignore_ascii_case(&key)).unwrap_or(false))
104    }
105
106    /// The field a directional move from `current` lands on (FORM-2), using the
107    /// shared spatial rule. Wraps to first/last when `wrap` and nothing lies that
108    /// way. Returns the new field index.
109    pub fn move_dir(&self, current: usize, dir: Dir4) -> Option<usize> {
110        let rects: Vec<Rect> = self.fields.iter().map(|f| f.rect).collect();
111        let cur = *rects.get(current)?;
112        if let Some(i) = nearest_in_direction(cur, &rects, dir) {
113            return Some(i);
114        }
115        if self.wrap && !self.fields.is_empty() {
116            return Some(match dir {
117                Dir4::Down | Dir4::Right => 0,
118                Dir4::Up | Dir4::Left => self.fields.len() - 1,
119            });
120        }
121        None
122    }
123}
124
125/// Convenience: motion progress for an animation that started `elapsed` seconds
126/// ago, eased with the theme's [`Motion`]. Deterministic given `elapsed` (the
127/// injected clock supplies it), so snapshots reproduce.
128pub fn motion_progress(motion: &Motion, elapsed: f32) -> f32 {
129    if motion.duration <= 0.0 {
130        return 1.0;
131    }
132    let lin = (elapsed / motion.duration).clamp(0.0, 1.0);
133    crate::effects::easing::ease_in_out_cubic(lin)
134}
135
136#[cfg(test)]
137mod tests {
138    use egui::{pos2, vec2};
139
140    use super::*;
141
142    fn rects() -> Vec<Rect> {
143        vec![
144            Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0)),
145            Rect::from_min_size(pos2(200.0, 0.0), vec2(100.0, 50.0)),
146            Rect::from_min_size(pos2(0.0, 100.0), vec2(100.0, 50.0)),
147        ]
148    }
149
150    #[test]
151    fn hints_label_each_focusable_deterministically() {
152        let focus = FocusSpec::default();
153        let h = hints(&focus, &rects());
154        assert_eq!(h.len(), 3);
155        assert_eq!(h[0].label, 'a');
156        assert_eq!(h[1].label, 's');
157        assert_eq!(h[2].label, 'd');
158        // Pressing 'S' jumps to focusable 1 (case-insensitive).
159        assert_eq!(hint_target(&h, 'S'), Some(1));
160        assert_eq!(hint_target(&h, 'z'), None);
161    }
162
163    #[test]
164    fn revolver_is_identity_when_disabled_or_effects_off() {
165        let target = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0));
166        let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
167        let off = FocusSpec { revolver_enabled: false, ..FocusSpec::default() };
168        assert_eq!(revolver_transform(target, vp, 1.0, EffectsPolicy::Full, &off), TSTransform::IDENTITY);
169        // Enabled but effects None (Device) → still identity (FOC-4 / §23).
170        let on = FocusSpec { revolver_enabled: true, ..FocusSpec::default() };
171        assert_eq!(revolver_transform(target, vp, 1.0, EffectsPolicy::None, &on), TSTransform::IDENTITY);
172    }
173
174    #[test]
175    fn revolver_centres_the_target_at_full_progress() {
176        let target = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0));
177        let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
178        let on = FocusSpec { revolver_enabled: true, ..FocusSpec::default() };
179        let tf = revolver_transform(target, vp, 1.0, EffectsPolicy::Full, &on);
180        // The target's centre should map onto (near) the viewport centre.
181        let mapped = tf.mul_pos(target.center());
182        assert!((mapped - vp.center()).length() < 1.0, "revolver brings target to viewport centre");
183        // At progress 0 it's the identity (in place).
184        let tf0 = revolver_transform(target, vp, 0.0, EffectsPolicy::Full, &on);
185        assert_eq!(tf0, TSTransform::IDENTITY);
186    }
187
188    #[test]
189    fn form_directional_move_and_hint_jump() {
190        let g = FocusGroup::new("filters")
191            .field("name", Some('n'), rects()[0])
192            .field("date", Some('d'), rects()[1])
193            .field("tags", Some('t'), rects()[2])
194            .wrap(true);
195        // Right from field 0 → field 1 (the one to its right).
196        assert_eq!(g.move_dir(0, Dir4::Right), Some(1));
197        // Down from field 0 → field 2 (below it).
198        assert_eq!(g.move_dir(0, Dir4::Down), Some(2));
199        // Left from field 0 wraps to the last field.
200        assert_eq!(g.move_dir(0, Dir4::Left), Some(2));
201        // Hint 'd' jumps to the date field.
202        assert_eq!(g.hint_target('D'), Some(1));
203    }
204
205    #[test]
206    fn motion_progress_is_monotonic_and_clamps() {
207        let m = Motion::default();
208        assert_eq!(motion_progress(&m, 0.0), 0.0);
209        assert!(motion_progress(&m, m.duration * 0.5) > 0.0);
210        assert_eq!(motion_progress(&m, m.duration * 2.0), 1.0);
211        // Zero-duration (Device) → instantly 1.
212        assert_eq!(motion_progress(&Motion { duration: 0.0, fast: 0.0 }, 0.0), 1.0);
213    }
214}