use egui::{Pos2, Rect, emath::TSTransform};
use crate::look::{EffectsPolicy, FocusSpec, Motion};
use crate::nav::{Dir4, nearest_in_direction};
#[derive(Clone, Debug, PartialEq)]
pub struct Hint {
pub label: char,
pub anchor: Pos2,
pub target: Rect,
}
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()
}
pub fn hint_target(hints: &[Hint], key: char) -> Option<usize> {
hints.iter().position(|h| h.label.eq_ignore_ascii_case(&key))
}
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);
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)
}
#[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>,
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
}
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))
}
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
}
}
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');
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);
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);
let mapped = tf.mul_pos(target.center());
assert!((mapped - vp.center()).length() < 1.0, "revolver brings target to viewport centre");
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);
assert_eq!(g.move_dir(0, Dir4::Right), Some(1));
assert_eq!(g.move_dir(0, Dir4::Down), Some(2));
assert_eq!(g.move_dir(0, Dir4::Left), Some(2));
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);
assert_eq!(motion_progress(&Motion { duration: 0.0, fast: 0.0 }, 0.0), 1.0);
}
}