1use egui::{Pos2, Rect, emath::TSTransform};
9
10use crate::look::{EffectsPolicy, FocusSpec, Motion};
11use crate::nav::{Dir4, nearest_in_direction};
12
13#[derive(Clone, Debug, PartialEq)]
16pub struct Hint {
17 pub label: char,
18 pub anchor: Pos2,
19 pub target: Rect,
20}
21
22pub 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
38pub fn hint_target(hints: &[Hint], key: char) -> Option<usize> {
40 hints.iter().position(|h| h.label.eq_ignore_ascii_case(&key))
41}
42
43pub 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 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#[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 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 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 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
125pub 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 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 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 let mapped = tf.mul_pos(target.center());
182 assert!((mapped - vp.center()).length() < 1.0, "revolver brings target to viewport centre");
183 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 assert_eq!(g.move_dir(0, Dir4::Right), Some(1));
197 assert_eq!(g.move_dir(0, Dir4::Down), Some(2));
199 assert_eq!(g.move_dir(0, Dir4::Left), Some(2));
201 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 assert_eq!(motion_progress(&Motion { duration: 0.0, fast: 0.0 }, 0.0), 1.0);
213 }
214}