use concinnity_core::components::{NavDirection, SettingVerb, UiAction};
use crate::settings::SettingKey;
const PERP_WEIGHT: f32 = 2.0;
#[derive(Debug, Clone)]
pub(crate) struct Candidate {
pub(crate) index: usize,
pub(crate) rect: [f32; 4],
pub(crate) action: Option<UiAction>,
}
#[derive(Debug, Clone)]
pub(crate) struct Target {
pub(crate) index: usize,
pub(crate) rect: [f32; 4],
pub(crate) setting: Option<SettingKey>,
}
#[derive(Debug, Clone)]
pub(crate) struct FocusRef {
pub(crate) index: usize,
pub(crate) rect: [f32; 4],
}
pub(crate) fn targets(candidates: &[Candidate]) -> Vec<Target> {
candidates
.iter()
.filter_map(|c| {
let setting = match c.action.as_ref()? {
UiAction::Setting {
verb: SettingVerb::Prev,
..
} => return None,
UiAction::Setting {
key,
verb: SettingVerb::Next | SettingVerb::Open | SettingVerb::Drag,
} => Some(*key),
_ => None,
};
Some(Target {
index: c.index,
rect: c.rect,
setting,
})
})
.collect()
}
fn center(rect: [f32; 4]) -> (f32, f32) {
(rect[0] + rect[2] * 0.5, rect[1] + rect[3] * 0.5)
}
fn distances(from: (f32, f32), to: (f32, f32), dir: NavDirection) -> (f32, f32) {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
match dir {
NavDirection::Up => (-dy, dx.abs()),
NavDirection::Down => (dy, dx.abs()),
NavDirection::Left => (-dx, dy.abs()),
NavDirection::Right => (dx, dy.abs()),
}
}
fn nearest_in_direction(
targets: &[Target],
from: (f32, f32),
dir: NavDirection,
skip: Option<usize>,
) -> Option<usize> {
targets
.iter()
.filter(|t| Some(t.index) != skip)
.filter_map(|t| {
let (ahead, perp) = distances(from, center(t.rect), dir);
(ahead > 0.5).then_some((t.index, ahead + perp * PERP_WEIGHT))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(index, _)| index)
}
pub(crate) fn initial(targets: &[Target]) -> Option<usize> {
targets
.iter()
.min_by(|a, b| {
let (ax, ay) = center(a.rect);
let (bx, by) = center(b.rect);
(ay, ax)
.partial_cmp(&(by, bx))
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|t| t.index)
}
pub(crate) fn navigate(
targets: &[Target],
current: Option<&FocusRef>,
dir: NavDirection,
) -> Option<usize> {
if targets.is_empty() {
return None;
}
let Some(current) = current else {
return initial(targets);
};
let from = center(current.rect);
let skip = targets
.iter()
.any(|t| t.index == current.index)
.then_some(current.index);
if let Some(next) = nearest_in_direction(targets, from, dir, skip) {
return Some(next);
}
match dir {
NavDirection::Up | NavDirection::Down => {
let flipped = match dir {
NavDirection::Up => NavDirection::Down,
_ => NavDirection::Up,
};
targets
.iter()
.filter(|t| Some(t.index) != skip)
.filter_map(|t| {
let (ahead, perp) = distances(from, center(t.rect), flipped);
(ahead > 0.5).then_some((t.index, ahead - perp * PERP_WEIGHT))
})
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(index, _)| index)
.or(Some(current.index))
}
NavDirection::Left | NavDirection::Right => Some(current.index),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cand(index: usize, x: f32, y: f32, action: serde_json::Value) -> Candidate {
Candidate {
index,
rect: [x, y, 200.0, 30.0],
action: serde_json::from_value(action).unwrap(),
}
}
fn focus(targets: &[Target], index: usize) -> FocusRef {
let t = targets.iter().find(|t| t.index == index).unwrap();
FocusRef {
index,
rect: t.rect,
}
}
#[test]
fn steppers_group_to_one_target_and_prev_twins_drop() {
let t = targets(&[
cand(
0,
0.0,
0.0,
serde_json::json!({"setting": {"key": "vsync", "verb": "prev"}}),
),
cand(
1,
60.0,
0.0,
serde_json::json!({"setting": {"key": "vsync", "verb": "next"}}),
),
cand(
2,
0.0,
40.0,
serde_json::json!({"setting": {"key": "exposure", "verb": "drag"}}),
),
cand(
3,
0.0,
80.0,
serde_json::json!({"setting": {"key": "window_mode", "verb": "open"}}),
),
cand(
4,
0.0,
120.0,
serde_json::json!({"setting": {"key": "key_forward", "verb": "rebind"}}),
),
cand(5, 0.0, 160.0, serde_json::json!({"group_toggle": 0})),
cand(6, 0.0, 200.0, serde_json::json!("hide")),
cand(7, 0.0, 240.0, serde_json::json!(null)),
]);
let by_index: Vec<usize> = t.iter().map(|t| t.index).collect();
assert_eq!(by_index, vec![1, 2, 3, 4, 5, 6], "prev + empty drop");
assert_eq!(t[0].setting, Some(SettingKey::Vsync));
assert_eq!(t[1].setting, Some(SettingKey::Exposure));
assert_eq!(t[2].setting, Some(SettingKey::WindowMode));
assert!(t[3].setting.is_none(), "a rebind row is not a value row");
}
#[test]
fn first_pulse_lands_topmost_leftmost() {
let t = targets(&[
cand(0, 100.0, 200.0, serde_json::json!("hide")),
cand(1, 0.0, 50.0, serde_json::json!("quit")),
cand(2, 300.0, 50.0, serde_json::json!({"scene": 1})),
]);
assert_eq!(navigate(&t, None, NavDirection::Down), Some(1));
assert_eq!(initial(&t), Some(1));
}
#[test]
fn vertical_list_walks_and_wraps() {
let t = targets(&[
cand(0, 0.0, 0.0, serde_json::json!({"show": 1})),
cand(1, 0.0, 50.0, serde_json::json!({"show": 2})),
cand(2, 0.0, 100.0, serde_json::json!({"show": 3})),
]);
let f0 = focus(&t, 0);
assert_eq!(navigate(&t, Some(&f0), NavDirection::Down), Some(1));
let f2 = focus(&t, 2);
assert_eq!(
navigate(&t, Some(&f2), NavDirection::Down),
Some(0),
"wraps to top"
);
assert_eq!(
navigate(&t, Some(&f0), NavDirection::Up),
Some(2),
"wraps to bottom"
);
}
#[test]
fn horizontal_neighbors_reachable_and_clamped_at_the_ends() {
let t = targets(&[
cand(0, 0.0, 0.0, serde_json::json!({"show": 1})),
cand(1, 250.0, 0.0, serde_json::json!({"show": 2})),
cand(2, 500.0, 0.0, serde_json::json!({"show": 3})),
]);
let f0 = focus(&t, 0);
assert_eq!(navigate(&t, Some(&f0), NavDirection::Right), Some(1));
let f2 = focus(&t, 2);
assert_eq!(
navigate(&t, Some(&f2), NavDirection::Right),
Some(2),
"clamps"
);
assert_eq!(navigate(&t, Some(&f2), NavDirection::Left), Some(1));
}
#[test]
fn straight_ahead_beats_nearer_but_far_off_axis() {
let t = targets(&[
cand(0, 200.0, 0.0, serde_json::json!({"show": 1})),
cand(1, 700.0, 40.0, serde_json::json!({"show": 11})),
cand(2, 200.0, 90.0, serde_json::json!({"show": 12})),
]);
let f0 = focus(&t, 0);
assert_eq!(navigate(&t, Some(&f0), NavDirection::Down), Some(2));
}
#[test]
fn vanished_focus_reanchors_from_its_last_rect() {
let t = targets(&[
cand(0, 0.0, 0.0, serde_json::json!({"show": 1})),
cand(2, 0.0, 100.0, serde_json::json!({"show": 3})),
]);
let gone = FocusRef {
index: 9,
rect: [0.0, 50.0, 200.0, 30.0],
};
assert_eq!(navigate(&t, Some(&gone), NavDirection::Down), Some(2));
assert_eq!(navigate(&t, Some(&gone), NavDirection::Up), Some(0));
}
#[test]
fn empty_targets_focus_nothing() {
assert_eq!(navigate(&[], None, NavDirection::Down), None);
let gone = FocusRef {
index: 0,
rect: [0.0, 0.0, 10.0, 10.0],
};
assert_eq!(navigate(&[], Some(&gone), NavDirection::Down), None);
}
}