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)
}
#[must_use]
pub fn fly_state(&self, seen: Rect) -> ([f64; 3], f64) {
let s = f64::from(self.scale.max(1e-9));
let c = seen.center();
(
[(f64::from(c.x) - f64::from(self.offset[0])) / s, (f64::from(c.y) - f64::from(self.offset[1])) / s, 0.0],
f64::from(seen.width().max(1.0)) / s,
)
}
pub fn set_fly_state(&mut self, c: [f64; 3], w: f64, seen: Rect) {
let s = f64::from(seen.width().max(1.0)) / w.max(1e-12);
let ctr = seen.center();
self.scale = s as f32;
self.offset = [(f64::from(ctr.x) - c[0] * s) as f32, (f64::from(ctr.y) - c[1] * s) as f32];
}
}
#[must_use]
pub fn canvas_size(ui: &egui::Ui) -> Vec2 {
let avail = ui.available_size();
let room = ui.clip_rect().max - ui.cursor().min;
let side = |a: f32, r: f32| if r >= 64.0 && a.is_finite() { a.min(r) } else if a.is_finite() { a } else { r.max(64.0) };
vec2(side(avail.x, room.x), side(avail.y, room.y))
}
#[derive(Debug, Default)]
pub struct CanvasTrack {
canvas: std::cell::Cell<Option<Rect>>,
seen: std::cell::Cell<Option<Rect>>,
}
impl Clone for CanvasTrack {
fn clone(&self) -> Self {
Self { canvas: std::cell::Cell::new(self.canvas.get()), seen: std::cell::Cell::new(self.seen.get()) }
}
}
impl CanvasTrack {
pub fn frame(&self, ui: &egui::Ui, rect: Rect) -> (Rect, Vec2) {
let seen = rect.intersect(ui.clip_rect());
let seen = if seen.width() > 1.0 && seen.height() > 1.0 { seen } else { rect };
let moved = self.canvas.get().map_or(Vec2::ZERO, |prev| rect.min - prev.min);
self.canvas.set(Some(rect));
self.seen.set(Some(seen));
(seen, moved)
}
pub fn seen(&self) -> Option<Rect> {
self.seen.get()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NavFlight {
pub flight: crate::fly::Flight,
pub seen: Rect,
}
impl NavFlight {
#[must_use]
pub fn to(nav: &Navigable, current: Option<NavFlight>, target: Pos2, scale: f32, seen: Rect, now: f64) -> Self {
let mut from = *nav;
if let Some(f) = current {
let (c, w) = f.flight.at(now);
from.set_fly_state(c, w, f.seen);
}
let (c0, w0) = from.fly_state(seen);
let w1 = f64::from(seen.width().max(1.0)) / f64::from(scale.max(1e-6));
Self { flight: crate::fly::Flight::new(now, c0, w0, [f64::from(target.x), f64::from(target.y), 0.0], w1), seen }
}
#[must_use]
pub fn fit(nav: &Navigable, current: Option<NavFlight>, bbox: Rect, seen: Rect, margin: f32, now: f64) -> Self {
let mut t = *nav;
t.fit(bbox, seen, margin);
Self::to(nav, current, bbox.center(), t.scale, seen, now)
}
pub fn tick(&self, nav: &mut Navigable, now: f64) -> bool {
let (c, w) = self.flight.at(now);
nav.set_fly_state(c, w, self.seen);
!self.flight.done(now)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NavGesture {
Pan(Vec2),
Zoom { factor: f32, anchor: Pos2 },
Fit,
}
pub fn gestures(ui: &mut egui::Ui, rect: Rect, resp: &egui::Response) -> Vec<NavGesture> {
use crate::look::{Action, keymap};
let mut messages = Vec::new();
if resp.dragged() {
messages.push(NavGesture::Pan(resp.drag_delta()));
}
let pointer = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
let anchor = if rect.contains(pointer) { pointer } else { rect.center() };
let (scroll, zoom, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
if resp.hovered() {
if (zoom - 1.0).abs() > 1e-4 {
messages.push(NavGesture::Zoom { factor: zoom, anchor });
} else if scroll.y != 0.0 && !modifiers.ctrl {
messages.push(NavGesture::Zoom { factor: (1.0 + scroll.y * 0.0015).clamp(0.5, 2.0), anchor });
}
if scroll.x != 0.0 {
messages.push(NavGesture::Pan(vec2(scroll.x, 0.0)));
}
}
if !(resp.hovered() || resp.has_focus()) || (ui.ctx().egui_wants_keyboard_input() && !resp.has_focus()) {
return messages;
}
let km = keymap(ui);
let mut delta = Vec2::ZERO;
for (action, movement) in [
(Action::Left, vec2(60.0, 0.0)),
(Action::Right, vec2(-60.0, 0.0)),
(Action::Up, vec2(0.0, 60.0)),
(Action::Down, vec2(0.0, -60.0)),
] {
if km.consume(action, ui) {
delta += movement;
}
}
if delta != Vec2::ZERO {
messages.push(NavGesture::Pan(delta));
}
if km.consume(Action::ZoomIn, ui) || km.consume(Action::PageUp, ui) {
messages.push(NavGesture::Zoom { factor: 1.2, anchor: rect.center() });
}
if km.consume(Action::ZoomOut, ui) || km.consume(Action::PageDown, ui) {
messages.push(NavGesture::Zoom { factor: 1.0 / 1.2, anchor: rect.center() });
}
if km.consume(Action::FitToView, ui)
|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F))
{
messages.push(NavGesture::Fit);
}
messages
}
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));
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");
}
}