use egui::{Pos2, Rect, Vec2, emath::TSTransform, vec2};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Dir4 {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Navigable {
pub scale: f32,
pub offset: [f32; 2],
pub min_scale: f32,
pub max_scale: f32,
}
impl Default for Navigable {
fn default() -> Self {
Self { scale: 1.0, offset: [0.0, 0.0], min_scale: 0.05, max_scale: 64.0 }
}
}
impl Navigable {
pub fn transform(&self) -> TSTransform {
TSTransform::new(Vec2::new(self.offset[0], self.offset[1]), self.scale)
}
pub fn pan(&mut self, delta: Vec2) {
self.offset[0] += delta.x;
self.offset[1] += delta.y;
}
pub fn zoom_to(&mut self, k: f32, screen_pivot: Pos2) {
let new_scale = (self.scale * k).clamp(self.min_scale, self.max_scale);
let actual_k = new_scale / self.scale;
self.offset[0] = screen_pivot.x - (screen_pivot.x - self.offset[0]) * actual_k;
self.offset[1] = screen_pivot.y - (screen_pivot.y - self.offset[1]) * actual_k;
self.scale = new_scale;
}
pub fn fit(&mut self, scene_bbox: Rect, viewport: Rect, margin: f32) {
if scene_bbox.width() <= 0.0 || scene_bbox.height() <= 0.0 {
return;
}
let m = margin.clamp(0.0, 0.45);
let avail = viewport.size() * (1.0 - 2.0 * m);
let sx = avail.x / scene_bbox.width();
let sy = avail.y / scene_bbox.height();
let s = sx.min(sy).clamp(self.min_scale, self.max_scale);
self.scale = s;
let bbox_center_scaled = scene_bbox.center().to_vec2() * s;
let vp_center = viewport.center().to_vec2();
self.offset = [vp_center.x - bbox_center_scaled.x, vp_center.y - bbox_center_scaled.y];
}
pub fn to_screen(&self, scene: Pos2) -> Pos2 {
self.transform().mul_pos(scene)
}
}
pub fn nearest_in_direction(current: Rect, candidates: &[Rect], dir: Dir4) -> Option<usize> {
let from = current.center();
let mut best: Option<(usize, f32)> = None;
for (i, &r) in candidates.iter().enumerate() {
let to = r.center();
let d = to - from;
let (along, across) = match dir {
Dir4::Left => (-d.x, d.y.abs()),
Dir4::Right => (d.x, d.y.abs()),
Dir4::Up => (-d.y, d.x.abs()),
Dir4::Down => (d.y, d.x.abs()),
};
if along <= 0.5 {
continue; }
let cost = along + across * 2.0;
if best.map(|(_, c)| cost < c).unwrap_or(true) {
best = Some((i, cost));
}
}
best.map(|(i, _)| i)
}
pub fn hint_anchors(rects: &[Rect]) -> Vec<Pos2> {
rects.iter().map(|r| r.left_top() + vec2(4.0, 4.0)).collect()
}
use std::collections::BTreeSet;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavLevel {
pub id: String,
pub label: String,
}
impl NavLevel {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self { id: id.into(), label: label.into() }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavFocus {
pub level: usize,
pub node: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DrillNav {
levels: Vec<NavLevel>,
history: Vec<NavFocus>,
expanded: BTreeSet<String>,
}
impl DrillNav {
pub fn new(levels: impl IntoIterator<Item = NavLevel>) -> Self {
let levels: Vec<NavLevel> = levels.into_iter().collect();
Self { levels, history: vec![NavFocus { level: 0, node: String::new() }], expanded: BTreeSet::new() }
}
pub fn warehouse() -> Self {
Self::new([
NavLevel::new("constellation", "Constellation"),
NavLevel::new("repo", "Repo"),
NavLevel::new("crate", "Crate"),
NavLevel::new("module", "Module"),
NavLevel::new("code", "Code"),
])
}
pub fn levels(&self) -> &[NavLevel] {
&self.levels
}
pub fn focus(&self) -> &NavFocus {
self.history.last().expect("history is never empty")
}
pub fn current_level(&self) -> usize {
self.focus().level
}
pub fn current_level_id(&self) -> &str {
self.levels.get(self.current_level()).map(|l| l.id.as_str()).unwrap_or("")
}
pub fn depth(&self) -> usize {
self.history.len()
}
pub fn drill(&mut self, node: impl Into<String>) {
let node = node.into();
let next = (self.current_level() + 1).min(self.levels.len().saturating_sub(1).max(0));
self.expanded.insert(node.clone());
self.history.push(NavFocus { level: next, node });
}
pub fn back(&mut self) -> bool {
if self.history.len() > 1 {
self.history.pop();
true
} else {
false
}
}
pub fn unfold(&mut self, node: impl Into<String>) {
self.expanded.insert(node.into());
}
pub fn fold(&mut self, node: &str) {
self.expanded.remove(node);
}
pub fn is_expanded(&self, node: &str) -> bool {
self.expanded.contains(node)
}
pub fn expanded(&self) -> &BTreeSet<String> {
&self.expanded
}
pub fn unfold_all<'a>(&mut self, all: impl IntoIterator<Item = &'a str>) {
for id in all {
self.expanded.insert(id.to_string());
}
}
pub fn visible<F>(&self, roots: impl IntoIterator<Item = String>, children: F) -> BTreeSet<String>
where
F: Fn(&str) -> Vec<String>,
{
let mut vis = BTreeSet::new();
let mut stack: Vec<String> = roots.into_iter().collect();
while let Some(n) = stack.pop() {
if !vis.insert(n.clone()) {
continue;
}
if self.expanded.contains(&n) {
for c in children(&n) {
if !vis.contains(&c) {
stack.push(c);
}
}
}
}
vis
}
pub fn breadcrumb(&self) -> Vec<(String, String)> {
self.history
.iter()
.map(|f| (self.levels.get(f.level).map(|l| l.id.clone()).unwrap_or_default(), f.node.clone()))
.collect()
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"levels": self.levels.iter().map(|l| l.id.clone()).collect::<Vec<_>>(),
"current_level": self.current_level(),
"current_level_id": self.current_level_id(),
"depth": self.depth(),
"focus": self.focus().node,
"breadcrumb": self.breadcrumb().into_iter().map(|(l, n)| serde_json::json!([l, n])).collect::<Vec<_>>(),
"expanded": self.expanded.iter().cloned().collect::<Vec<_>>(),
"expanded_count": self.expanded.len(),
})
}
}
#[cfg(test)]
mod tests {
use egui::pos2;
use super::*;
fn kids(n: &str) -> Vec<String> {
match n {
"root" => vec!["a".into(), "b".into()],
"a" => vec!["a1".into(), "a2".into()],
"b" => vec!["b1".into()],
_ => vec![],
}
}
#[test]
fn drill_advances_the_level_and_back_returns_to_the_parent() {
let mut nav = DrillNav::warehouse();
assert_eq!(nav.current_level_id(), "constellation");
assert_eq!(nav.depth(), 1);
nav.drill("nornir"); assert_eq!(nav.current_level_id(), "repo");
nav.drill("nornir-warehouse"); assert_eq!(nav.current_level_id(), "crate");
assert_eq!(nav.depth(), 3);
assert!(nav.back());
assert_eq!(nav.current_level_id(), "repo", "back returns to the parent level");
assert_eq!(nav.focus().node, "nornir");
assert!(nav.back());
assert_eq!(nav.current_level_id(), "constellation");
assert!(!nav.back(), "back at the root does nothing");
assert_eq!(nav.depth(), 1);
}
#[test]
fn unfold_keeps_the_parent_and_all_ancestors_visible() {
let mut nav = DrillNav::warehouse();
nav.unfold("root");
let roots = || ["root".to_string()];
let v = nav.visible(roots(), kids);
assert!(v.contains("root") && v.contains("a") && v.contains("b"));
assert!(!v.contains("a1"), "a's children hidden until a is unfolded");
nav.unfold("a");
let v = nav.visible(roots(), kids);
assert!(v.contains("a1") && v.contains("a2"), "a unfolded its children");
assert!(v.contains("root"), "the ROOT (ancestor) is still visible after unfold");
assert!(v.contains("a"), "the PARENT is still visible after unfold");
assert!(v.contains("b"), "the sibling stays too");
nav.fold("a");
let v = nav.visible(roots(), kids);
assert!(!v.contains("a1") && v.contains("a"), "fold hides children, keeps the node");
}
#[test]
fn drill_unfolds_the_focused_node_so_children_show_in_place() {
let mut nav = DrillNav::warehouse();
nav.unfold("root");
nav.drill("a"); assert!(nav.is_expanded("a"));
let v = nav.visible(["root".to_string()], kids);
assert!(v.contains("a1") && v.contains("root"), "drilling opened a in place, root stays");
}
#[test]
fn drillnav_state_json_and_serde_round_trip() {
let mut nav = DrillNav::warehouse();
nav.drill("nornir");
nav.unfold("nornir");
let j = nav.state_json();
assert_eq!(j["current_level_id"], "repo");
assert_eq!(j["depth"], 2);
assert_eq!(j["expanded_count"], 1);
let back: DrillNav = serde_json::from_value(serde_json::to_value(&nav).unwrap()).unwrap();
assert_eq!(back, nav);
}
#[test]
fn zoom_keeps_the_pivot_point_fixed() {
let mut nav = Navigable::default();
let pivot = pos2(200.0, 150.0);
let before = inverse(&nav, pivot);
nav.zoom_to(2.0, pivot);
let after = inverse(&nav, pivot);
assert!((before - after).length() < 1e-3, "scene point under cursor must stay put");
assert_eq!(nav.scale, 2.0);
}
fn inverse(nav: &Navigable, screen: Pos2) -> Pos2 {
pos2((screen.x - nav.offset[0]) / nav.scale, (screen.y - nav.offset[1]) / nav.scale)
}
#[test]
fn zoom_clamps_to_range() {
let mut nav = Navigable::default();
for _ in 0..100 {
nav.zoom_to(2.0, pos2(0.0, 0.0));
}
assert!(nav.scale <= nav.max_scale + 1e-3);
for _ in 0..100 {
nav.zoom_to(0.5, pos2(0.0, 0.0));
}
assert!(nav.scale >= nav.min_scale - 1e-3);
}
#[test]
fn fit_centres_and_scales_a_bbox() {
let mut nav = Navigable::default();
let bbox = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0));
let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 400.0));
nav.fit(bbox, vp, 0.1);
let c = nav.to_screen(bbox.center());
assert!((c - vp.center()).length() < 1.0, "bbox centre → viewport centre");
assert!((nav.scale - 3.2).abs() < 0.1, "fit scale, got {}", nav.scale);
}
#[test]
fn spatial_focus_picks_nearest_in_direction_not_tab_order() {
let current = Rect::from_center_size(pos2(100.0, 100.0), vec2(40.0, 20.0));
let right = Rect::from_center_size(pos2(180.0, 105.0), vec2(40.0, 20.0));
let up = Rect::from_center_size(pos2(100.0, 20.0), vec2(40.0, 20.0));
let cands = [up, right];
assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1));
assert_eq!(nearest_in_direction(current, &cands, Dir4::Up), Some(0));
assert_eq!(nearest_in_direction(current, &cands, Dir4::Left), None);
}
#[test]
fn spatial_focus_prefers_on_axis_over_diagonal() {
let current = Rect::from_center_size(pos2(0.0, 0.0), vec2(10.0, 10.0));
let straight = Rect::from_center_size(pos2(100.0, 0.0), vec2(10.0, 10.0));
let diagonal = Rect::from_center_size(pos2(90.0, 90.0), vec2(10.0, 10.0));
let cands = [diagonal, straight];
assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1), "straight beats diagonal");
}
}