use std::collections::{HashMap, HashSet};
use std::f32::consts::{PI, TAU};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use glam::Vec3;
use notify::Watcher;
use yakui::Pivot;
use yakui::geometry::Vec2 as UiVec2;
use crate::OrbitCamera;
use crate::ecs::Entity;
use crate::gizmos::{WireDrawList, Wires};
use crate::input::KeyCode;
use crate::scene::Scene;
use crate::state::AppState;
use crate::ui::{self, Alpha, Color, PointerCapture, yakui_color};
use crate::views::View;
use super::forces::Forces;
use super::graph::{Graph, Id, Kind, Relation};
use super::layout::Layout;
use super::thumbs::{self, Thumbnails};
const PICK_PX: f32 = 18.0;
const DEPENDENT_END: Color = Color::srgb(0.95, 0.95, 0.98);
const FLY_SECONDS: f32 = 0.7;
const DOUBLE_CLICK: f32 = 0.4;
const REINDEX_DELAY: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Edges {
Selected,
All,
None,
}
impl Edges {
pub fn label(self) -> &'static str {
match self {
Edges::Selected => "selected node",
Edges::All => "all",
Edges::None => "none",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Force {
Off,
Gentle,
Strong,
}
impl Force {
pub fn label(self) -> &'static str {
match self {
Force::Off => "off",
Force::Gentle => "gentle",
Force::Strong => "strong",
}
}
fn strength(self) -> Option<f32> {
match self {
Force::Off => None,
Force::Gentle => Some(1.0),
Force::Strong => Some(3.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Knob {
Fill,
Forces,
Depth,
Near,
Floor,
Range,
Max,
Edges,
Private,
Reset,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Options {
pub open_depth: usize,
pub near: f32,
pub label_floor: f32,
pub label_range: f32,
pub max_labels: usize,
pub edges: Edges,
pub private: bool,
pub forces: Force,
pub fill: f32,
}
impl Default for Options {
fn default() -> Self {
Self {
fill: 0.05,
forces: Force::Off,
open_depth: usize::MAX,
near: 4.0,
label_floor: 8.0,
label_range: 20.0,
max_labels: 400,
edges: Edges::Selected,
private: true,
}
}
}
impl Options {
pub fn turn(&mut self, knob: Knob) {
match knob {
Knob::Fill => self.fill = next(self.fill, &[0.0, 0.03, 0.05, 0.1, 0.2]),
Knob::Forces => {
self.forces = next(self.forces, &[Force::Off, Force::Gentle, Force::Strong])
}
Knob::Depth => self.open_depth = next(self.open_depth, &[1, 2, 3, 4, usize::MAX]),
Knob::Near => self.near = next(self.near, &[0.0, 4.0, 8.0, 16.0]),
Knob::Floor => self.label_floor = next(self.label_floor, &[6.0, 8.0, 10.0, 12.0]),
Knob::Range => {
self.label_range = next(self.label_range, &[10.0, 20.0, 40.0, 80.0, 160.0])
}
Knob::Max => self.max_labels = next(self.max_labels, &[200, 400, 800, 1600]),
Knob::Edges => {
self.edges = next(self.edges, &[Edges::Selected, Edges::All, Edges::None])
}
Knob::Private => self.private = !self.private,
Knob::Reset => *self = Self::default(),
}
}
pub fn depth_label(&self) -> String {
match self.open_depth {
usize::MAX => "everything".into(),
n => format!("{n} deep"),
}
}
pub fn near_label(&self) -> String {
match self.near {
0.0 => "off".into(),
n => format!("{n}× radius"),
}
}
}
fn next<T: PartialEq + Copy>(current: T, choices: &[T]) -> T {
let at = choices.iter().position(|&c| c == current);
choices[at.map_or(0, |i| (i + 1) % choices.len())]
}
pub struct Model {
pub graph: Graph,
pub layout: Layout,
open: HashSet<String>,
shut: HashSet<String>,
pub selected: Option<Id>,
pub hovered: Option<Id>,
pub search: Option<String>,
pub options: Options,
forces: Forces,
}
impl Model {
pub fn new(graph: Graph) -> Self {
let layout = Layout::of(&graph);
let forces = Forces::new(&graph);
Self {
graph,
layout,
forces,
open: HashSet::new(),
shut: HashSet::new(),
selected: None,
hovered: None,
search: None,
options: Options::default(),
}
}
pub fn retarget(&mut self, graph: Graph) {
let path = |id: Option<Id>| id.map(|id| self.graph.node(id).path.clone());
let selected = path(self.selected);
let hovered = path(self.hovered);
self.layout = Layout::of(&graph);
self.forces = Forces::new(&graph);
self.graph = graph;
self.selected = selected.and_then(|p| self.graph.find(&p));
self.hovered = hovered.and_then(|p| self.graph.find(&p));
}
pub fn tick(&mut self) {
match self.options.forces.strength() {
Some(strength) => {
if !self.forces.is_settled() || self.forces.links() > 0 {
for _ in 0..2 {
self.forces.step(&self.graph, &mut self.layout, strength);
}
}
}
None => {
self.forces.relax(&self.graph, &mut self.layout);
}
}
}
pub fn links(&self) -> usize {
self.forces.links()
}
pub fn is_open(&self, id: Id, eye: Vec3) -> bool {
let node = self.graph.node(id);
if self.shut.contains(&node.path) {
return false;
}
match node.kind {
Kind::Workspace => true,
kind if kind.is_container() => {
self.open.contains(&node.path)
|| self.graph.depth(id) <= self.options.open_depth
|| (self.options.near > 0.0
&& eye.distance(self.layout.position(id))
< self.options.near * self.layout.radius_of(id).max(1.0))
}
_ => false,
}
}
pub fn visible(&self, eye: Vec3) -> Vec<Id> {
let mut out = Vec::new();
let mut stack = vec![self.graph.root()];
while let Some(id) = stack.pop() {
out.push(id);
if self.is_open(id, eye) {
let mut children = self.graph.children(id);
if !self.options.private {
children.retain(|&child| {
let node = self.graph.node(child);
node.public || node.kind.is_container()
});
}
children.reverse();
stack.extend(children);
}
}
out
}
pub fn toggle(&mut self, id: Id, eye: Vec3) {
let node = self.graph.node(id);
if !node.kind.is_container() {
return;
}
let path = node.path.clone();
if self.is_open(id, eye) {
self.open.remove(&path);
self.shut.insert(path);
} else {
self.shut.remove(&path);
self.open.insert(path);
}
}
pub fn reveal(&mut self, id: Id) {
for ancestor in self.graph.ancestors(id) {
let node = self.graph.node(ancestor);
if node.kind.is_container() {
self.shut.remove(&node.path);
}
if node.kind.is_container() && node.kind != Kind::Workspace {
self.open.insert(node.path.clone());
}
}
}
pub fn open_all(&mut self) {
self.shut.clear();
self.open = self.module_paths();
}
pub fn close_all(&mut self) {
self.open.clear();
self.shut = self.module_paths();
}
fn module_paths(&self) -> HashSet<String> {
self.graph
.ids()
.filter(|&id| {
let kind = self.graph.node(id).kind;
kind.is_container() && kind != Kind::Workspace
})
.map(|id| self.graph.node(id).path.clone())
.collect()
}
pub fn matches(&self, query: &str) -> Vec<Id> {
let query = query.to_lowercase();
if query.is_empty() {
return Vec::new();
}
let mut found: Vec<Id> = self
.graph
.ids()
.filter(|&id| self.graph.node(id).name.to_lowercase().contains(&query))
.collect();
found.sort_by_key(|&id| {
let node = self.graph.node(id);
(
!node.name.eq_ignore_ascii_case(&query),
node.name.len(),
node.path.clone(),
)
});
found
}
pub fn anchor(&self, id: Id) -> Vec3 {
let front = self.layout.front_of(id);
let size = self.layout.size_of(id);
match self.graph.node(id).kind.is_container() {
true => front + Vec3::new(0.4 - size.x * 0.5, size.y * 0.5 - 0.15, 0.0),
false => front,
}
}
pub fn pick(&self, view: &View, cursor: (f32, f32), visible: &[Id]) -> Option<Id> {
let eye = view.camera.eye;
let mut best: Option<(f32, f32, Id)> = None;
for &id in visible {
let position = self.anchor(id);
let Some((x, y)) = view.project(position) else {
continue;
};
let apart = ((x - cursor.0).powi(2) + (y - cursor.1).powi(2)).sqrt();
if apart > PICK_PX {
continue;
}
let depth = eye.distance(position);
let key = (apart.round(), depth);
if best.is_none_or(|(a, d, _)| key < (a, d)) {
best = Some((key.0, key.1, id));
}
}
best.map(|(_, _, id)| id)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Stroke {
Kind(Kind),
Tree,
Depends,
Implements,
Uses,
Highlight,
Match,
}
impl Stroke {
const ALL: [Stroke; 20] = [
Stroke::Tree,
Stroke::Depends,
Stroke::Implements,
Stroke::Uses,
Stroke::Kind(Kind::Workspace),
Stroke::Kind(Kind::Crate),
Stroke::Kind(Kind::Dir),
Stroke::Kind(Kind::File),
Stroke::Kind(Kind::Module),
Stroke::Kind(Kind::Struct),
Stroke::Kind(Kind::Enum),
Stroke::Kind(Kind::Union),
Stroke::Kind(Kind::Trait),
Stroke::Kind(Kind::Fn),
Stroke::Kind(Kind::Const),
Stroke::Kind(Kind::Static),
Stroke::Kind(Kind::TypeAlias),
Stroke::Kind(Kind::Macro),
Stroke::Match,
Stroke::Highlight,
];
fn color(self) -> Color {
let (r, g, b) = match self {
Stroke::Kind(kind) => kind_rgb(kind),
Stroke::Tree => (0.30, 0.32, 0.40),
Stroke::Depends => (0.55, 0.75, 1.0),
Stroke::Implements => (0.75, 0.40, 0.90),
Stroke::Uses => (0.35, 0.55, 0.95),
Stroke::Highlight => (1.0, 0.95, 0.30),
Stroke::Match => (1.0, 0.60, 0.20),
};
Color::srgb(r, g, b)
}
}
fn kind_rgb(kind: Kind) -> (f32, f32, f32) {
match kind {
Kind::Workspace | Kind::Crate => (0.95, 0.95, 0.98),
Kind::Dir | Kind::Module => (0.45, 0.65, 1.0),
Kind::File => (0.78, 0.82, 0.92),
Kind::Struct => (0.40, 0.85, 0.50),
Kind::Enum => (0.95, 0.80, 0.30),
Kind::Union => (0.80, 0.60, 0.40),
Kind::Trait => (0.85, 0.45, 0.95),
Kind::Fn => (0.60, 0.65, 0.75),
Kind::Const | Kind::Static => (0.95, 0.60, 0.35),
Kind::TypeAlias => (0.40, 0.85, 0.85),
Kind::Macro => (0.95, 0.50, 0.70),
}
}
fn kind_color(kind: Kind) -> yakui::geometry::Color {
let (r, g, b) = kind_rgb(kind);
yakui_color(Color::srgb(r, g, b))
}
fn polygon(wires: &mut Wires, centre: Vec3, radius: f32, sides: usize, offset: f32) {
let point = |i: usize| {
let angle = offset + TAU * i as f32 / sides as f32;
centre + Vec3::new(radius * angle.cos(), radius * angle.sin(), 0.0)
};
for i in 0..sides {
wires.line(point(i), point((i + 1) % sides));
}
}
const EDGE_ALPHA: f32 = 0.55;
fn level_hue(level: usize) -> f32 {
(18.0 - 28.0 * level as f32).rem_euclid(360.0)
}
fn level_color(level: usize) -> Color {
Color::hsl(level_hue(level), 0.85, 0.55)
}
fn shell(wires: &mut Wires, centre: Vec3, half: Vec3, color: Color, fill: f32) {
let corner = |i: usize, y: f32| {
let (sx, sz) = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)][i];
Vec3::new(centre.x + sx * half.x, y, centre.z + sz * half.z)
};
let (top, bottom) = (centre.y + half.y, centre.y - half.y);
let edge = [color.with_alpha(EDGE_ALPHA); 2];
for i in 0..4 {
wires.gradient(corner(i, top), corner((i + 1) % 4, top), edge);
wires.gradient(corner(i, bottom), corner((i + 1) % 4, bottom), edge);
wires.gradient(corner(i, top), corner(i, bottom), edge);
}
if fill <= 0.0 {
return;
}
let face = color.with_alpha(fill);
let ring = |y: f32| [corner(0, y), corner(1, y), corner(2, y), corner(3, y)];
wires.quad(ring(top), face);
wires.quad(ring(bottom), face);
for i in 0..4 {
let j = (i + 1) % 4;
wires.quad(
[
corner(i, top),
corner(j, top),
corner(j, bottom),
corner(i, bottom),
],
face,
);
}
}
fn glyph(wires: &mut Wires, kind: Kind, at: Vec3) {
match kind {
Kind::Workspace | Kind::Crate | Kind::Dir | Kind::File | Kind::Module => {
polygon(wires, at, 0.3, 4, 0.0)
}
Kind::Struct => polygon(wires, at, 0.2, 4, PI / 4.0),
Kind::Enum => polygon(wires, at, 0.22, 3, PI / 2.0),
Kind::Union => polygon(wires, at, 0.2, 5, PI / 2.0),
Kind::Trait => polygon(wires, at, 0.22, 10, 0.0),
Kind::Fn => {
wires.line(at - Vec3::X * 0.14, at + Vec3::X * 0.14);
wires.line(at - Vec3::Z * 0.14, at + Vec3::Z * 0.14);
}
Kind::Const | Kind::Static => polygon(wires, at, 0.14, 4, 0.0),
Kind::TypeAlias => polygon(wires, at, 0.18, 6, 0.0),
Kind::Macro => {
polygon(wires, at, 0.2, 3, PI / 2.0);
polygon(wires, at, 0.2, 3, -PI / 2.0);
}
}
}
struct Fly {
from: (Vec3, f32),
to: (Vec3, f32),
t: f32,
}
struct Watch {
_watcher: notify::RecommendedWatcher,
changed: Receiver<()>,
}
impl Watch {
fn start(root: &Path) -> Option<Self> {
let (tx, changed) = mpsc::channel();
let relevant = |path: &Path| {
let source = path.extension().is_some_and(|e| e == "rs")
|| path.file_name().is_some_and(|n| n == "Cargo.toml");
source && !path.components().any(|c| c.as_os_str() == "target")
};
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& event.paths.iter().any(|p| relevant(p))
{
let _ = tx.send(());
}
})
.ok()?;
if let Err(error) = watcher.watch(root, notify::RecursiveMode::Recursive) {
log::warn!("not watching {}: {error}", root.display());
return None;
}
Some(Self {
_watcher: watcher,
changed,
})
}
}
pub struct CodeScene {
root: PathBuf,
model: Option<Model>,
indexing: Option<Receiver<Result<Graph, String>>>,
started: Instant,
error: Option<String>,
rig: Option<Entity>,
strokes: Vec<(Stroke, Entity)>,
fly: Option<Fly>,
last_click: Option<(Id, f32)>,
watch: Option<Watch>,
reindex_at: Option<Instant>,
move_speed: f32,
menu: Option<usize>,
thumbs: Thumbnails,
fonts: HashMap<PathBuf, Option<String>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Reindex,
Quit,
Fit,
Focus,
Search,
Clear,
OpenAll,
CloseAll,
Dev,
Turn(Knob),
}
const MENUS: [&str; 3] = ["FILE", "VIEW", "OPTIONS"];
impl CodeScene {
pub fn open(root: PathBuf) -> Self {
let mut scene = Self::with_model(root.clone(), None);
scene.watch = Watch::start(&root);
scene.start_indexing();
scene
}
pub fn with_graph(graph: Graph) -> Self {
Self::with_model(PathBuf::new(), Some(Model::new(graph)))
}
fn with_model(root: PathBuf, model: Option<Model>) -> Self {
Self {
root,
model,
indexing: None,
started: Instant::now(),
error: None,
rig: None,
strokes: Vec::new(),
fly: None,
last_click: None,
watch: None,
reindex_at: None,
move_speed: OrbitCamera::default().move_speed,
menu: None,
thumbs: Thumbnails::new(),
fonts: HashMap::new(),
}
}
fn draw_menu(&mut self) -> Option<Action> {
let bar = ui::menu_bar(&MENUS, self.menu);
if let Some(clicked) = bar.clicked {
self.menu = (self.menu != Some(clicked)).then_some(clicked);
} else if self.menu.is_some() && bar.hovered.is_some() {
self.menu = bar.hovered;
}
let open = self.menu?;
let options = self.model.as_ref().map(|m| m.options).unwrap_or_default();
let entries: Vec<(String, Action)> = match open {
0 => vec![
("Re-index".into(), Action::Reindex),
("Quit".into(), Action::Quit),
],
1 => vec![
("Fit everything Home".into(), Action::Fit),
("Focus selected F".into(), Action::Focus),
("Search /".into(), Action::Search),
("Clear selection Esc".into(), Action::Clear),
("Open every module".into(), Action::OpenAll),
("Close every module".into(), Action::CloseAll),
("Dev overlay F12".into(), Action::Dev),
],
_ => vec![
(
format!("Box fill: {:.0}%", options.fill * 100.0),
Action::Turn(Knob::Fill),
),
(
format!("Forces between linked modules: {}", options.forces.label()),
Action::Turn(Knob::Forces),
),
(
format!("Open levels: {}", options.depth_label()),
Action::Turn(Knob::Depth),
),
(
format!("Auto-open near the camera: {}", options.near_label()),
Action::Turn(Knob::Near),
),
(
format!("Hide labels under: {} px", options.label_floor),
Action::Turn(Knob::Floor),
),
(
format!("Symbol labels full size within: {}", options.label_range),
Action::Turn(Knob::Range),
),
(
format!("Labels at most: {}", options.max_labels),
Action::Turn(Knob::Max),
),
(
format!("Relation lines: {}", options.edges.label()),
Action::Turn(Knob::Edges),
),
(
format!(
"Private symbols: {}",
if options.private { "shown" } else { "hidden" }
),
Action::Turn(Knob::Private),
),
("Reset options".into(), Action::Turn(Knob::Reset)),
],
};
let mut chosen = None;
ui::dropdown(bar.starts[open], || {
for (label, action) in entries {
if ui::menu_row(label, None, None).clicked {
chosen = Some(action);
}
}
});
if chosen.is_some_and(|action| !matches!(action, Action::Turn(_))) {
self.menu = None;
}
chosen
}
fn act(&mut self, app: &mut AppState, action: Action) {
match action {
Action::Reindex => {
if self.indexing.is_none() {
self.start_indexing();
}
}
Action::Quit => std::process::exit(0),
Action::Fit => self.frame_camera(app),
Action::Focus => {
if let Some(id) = self.model.as_ref().and_then(|m| m.selected) {
self.fly_to(app, id);
}
}
Action::Search => self.set_searching(app, true),
Action::Clear => {
if let Some(model) = &mut self.model {
model.selected = None;
}
}
Action::OpenAll => {
if let Some(model) = &mut self.model {
model.open_all();
}
}
Action::CloseAll => {
if let Some(model) = &mut self.model {
model.close_all();
}
}
Action::Dev => app.set_dev_mode(!app.dev_mode()),
Action::Turn(knob) => {
if let Some(model) = &mut self.model {
model.options.turn(knob);
}
}
}
}
fn start_indexing(&mut self) {
let (tx, rx) = mpsc::channel();
let root = self.root.clone();
std::thread::spawn(move || {
let started = Instant::now();
let result = super::index(&root);
if let Ok(graph) = &result {
log::info!(
"indexed {}: {} nodes in {:?}",
root.display(),
graph.len(),
started.elapsed()
);
}
let _ = tx.send(result);
});
self.indexing = Some(rx);
self.started = Instant::now();
}
fn poll_indexing(&mut self, app: &mut AppState) {
let Some(rx) = &self.indexing else {
return;
};
match rx.try_recv() {
Ok(Ok(graph)) => {
self.indexing = None;
self.error = None;
match &mut self.model {
Some(model) => model.retarget(graph),
None => {
self.model = Some(Model::new(graph));
self.frame_camera(app);
}
}
}
Ok(Err(error)) => {
self.indexing = None;
self.error = Some(error);
}
Err(mpsc::TryRecvError::Disconnected) => {
self.indexing = None;
self.error = Some("indexing stopped".into());
}
Err(mpsc::TryRecvError::Empty) => {}
}
}
fn poll_watch(&mut self) {
if let Some(watch) = &self.watch
&& watch.changed.try_iter().count() > 0
{
self.reindex_at = Some(Instant::now() + REINDEX_DELAY);
}
if self.indexing.is_none() && self.reindex_at.is_some_and(|at| Instant::now() >= at) {
self.reindex_at = None;
self.start_indexing();
}
}
fn frame_camera(&mut self, app: &mut AppState) {
let (Some(model), Some(rig)) = (&self.model, self.rig) else {
return;
};
let root = model.graph.root();
let front = model.layout.front_of(root);
let size = model.layout.size_of(root);
let extent = size.truncate().length() * 0.5;
app.edit::<OrbitCamera>(rig, |rig| {
rig.focus = front;
rig.yaw = 0.0;
rig.pitch = 0.08;
rig.distance = extent * 2.4 + 2.0;
});
}
fn fly_to(&mut self, app: &mut AppState, id: Id) {
let (Some(model), Some(rig)) = (&self.model, self.rig) else {
return;
};
let Some(current) = app.get::<OrbitCamera>(rig) else {
return;
};
let to = (
model.layout.position(id),
(model.layout.radius_of(id) * 2.5 + 2.0).max(5.0),
);
self.fly = Some(Fly {
from: (current.focus, current.distance),
to,
t: 0.0,
});
}
fn tick_fly(&mut self, app: &mut AppState) {
let Some(fly) = &mut self.fly else {
return;
};
fly.t = (fly.t + app.time().delta / FLY_SECONDS).min(1.0);
let s = fly.t * fly.t * (3.0 - 2.0 * fly.t);
let focus = fly.from.0.lerp(fly.to.0, s);
let distance = fly.from.1 + (fly.to.1 - fly.from.1) * s;
let done = fly.t >= 1.0;
if let Some(rig) = self.rig {
app.edit::<OrbitCamera>(rig, |rig| {
rig.focus = focus;
rig.distance = distance;
});
}
if done {
self.fly = None;
}
}
fn jump(&mut self, app: &mut AppState, id: Id) {
if let Some(model) = &mut self.model {
model.selected = Some(id);
model.reveal(id);
}
self.fly_to(app, id);
}
fn set_searching(&mut self, app: &mut AppState, on: bool) {
let Some(model) = &mut self.model else {
return;
};
if on == model.search.is_some() {
return;
}
model.search = on.then(String::new);
let speed = if on { 0.0 } else { self.move_speed };
if let Some(rig) = self.rig {
app.edit::<OrbitCamera>(rig, |rig| rig.move_speed = speed);
}
}
fn handle_keys(&mut self, app: &mut AppState) {
let keys = app.keys();
let searching = self.model.as_ref().is_some_and(|m| m.search.is_some());
if searching {
let mut typed = String::new();
let mut backspace = false;
for (code, letter) in LETTERS {
if keys.just_pressed(*code) {
typed.push(*letter);
}
}
if keys.just_pressed(KeyCode::Backspace) {
backspace = true;
}
let enter = keys.just_pressed(KeyCode::Enter);
let escape = keys.just_pressed(KeyCode::Escape);
let model = self.model.as_mut().expect("searching needs a model");
let query = model.search.as_mut().expect("searching");
query.push_str(&typed);
if backspace {
query.pop();
}
let query = query.clone();
let first = enter
.then(|| model.matches(&query).first().copied())
.flatten();
if escape {
self.set_searching(app, false);
} else if let Some(id) = first {
self.set_searching(app, false);
self.jump(app, id);
}
return;
}
if keys.just_pressed(KeyCode::Slash) {
self.set_searching(app, true);
} else if keys.just_pressed(KeyCode::Escape) {
if self.menu.take().is_none()
&& let Some(model) = &mut self.model
{
model.selected = None;
}
} else if keys.just_pressed(KeyCode::KeyF) {
if let Some(id) = self.model.as_ref().and_then(|m| m.selected) {
self.fly_to(app, id);
}
} else if keys.just_pressed(KeyCode::Home) {
self.frame_camera(app);
}
}
fn handle_mouse(&mut self, app: &mut AppState, view: &View, visible: &[Id]) {
let taken = app.ecs.world.resource::<PointerCapture>().taken();
let mouse = app.mouse();
let cursor = app.cursor();
let elapsed = app.time().elapsed;
let eye = view.camera.eye;
let Some(model) = &mut self.model else {
return;
};
model.hovered = (!taken)
.then(|| model.pick(view, cursor, visible))
.flatten();
if !mouse.just_pressed || taken {
return;
}
self.menu = None;
match model.hovered {
Some(id) => {
let again = self
.last_click
.is_some_and(|(last, at)| last == id && elapsed - at < DOUBLE_CLICK);
self.last_click = Some((id, elapsed));
model.selected = Some(id);
if again {
self.fly_to(app, id);
} else if model.graph.node(id).kind.is_container() {
model.toggle(id, eye);
}
}
None => {
model.selected = None;
self.last_click = None;
}
}
}
fn draw_wires(&mut self, app: &mut AppState, view: &View, visible: &[Id], eye: Vec3) {
let Some(model) = &self.model else {
return;
};
let graph = &model.graph;
let layout = &model.layout;
let shown: HashSet<Id> = visible.iter().copied().collect();
let mut wires: HashMap<Stroke, Wires> = Stroke::ALL
.iter()
.map(|&stroke| (stroke, Wires::new(stroke.color())))
.collect();
let matches: HashSet<Id> = model
.search
.as_deref()
.map(|q| model.matches(q).into_iter().collect())
.unwrap_or_default();
for &id in visible {
let node = graph.node(id);
let at = layout.position(id);
let mark = model.anchor(id);
let own = wires.get_mut(&Stroke::Kind(node.kind)).expect("stroke");
if node.kind.is_container() {
let grown = Vec3::splat(0.06 * layout.height_of(id) as f32);
let half = layout.size_of(id) * 0.5 + grown;
let color = level_color(graph.depth(id));
shell(own, at, half, color, model.options.fill);
if node.kind == Kind::File && graph.children(id).is_empty() {
let size = layout.size_of(id);
let front = layout.front_of(id) + Vec3::Z * 0.02;
let top_left = front + Vec3::new(-size.x * 0.5, size.y * 0.5, 0.0);
let top_right = front + Vec3::new(size.x * 0.5, size.y * 0.5, 0.0);
let across = match (
view.project_unclipped(top_left),
view.project_unclipped(top_right),
) {
(Some(a), Some(b))
if view.is_near(a.0, a.1, 300.0) || view.is_near(b.0, b.1, 300.0) =>
{
(b.0 - a.0).abs()
}
_ => 0.0,
};
let live_font = thumbs::is_font(Path::new(&node.name)) && across >= 48.0;
if across >= 10.0 && !live_font {
let picture = node
.file
.as_deref()
.and_then(|path| self.thumbs.get(path, across));
let (thumb, inset, tint) = match picture {
Some(thumb) => (Some(thumb), 0.06, Color::WHITE),
None => (
self.thumbs.icon(thumbs::icon_for(Path::new(&node.name))),
0.28,
Color::srgb(0.85, 0.87, 0.95).with_alpha(0.9),
),
};
if let Some(thumb) = thumb {
let mut hx = size.x * (0.5 - inset);
let mut hy = size.y * (0.5 - inset);
if thumb.aspect > hx / hy {
hy = hx / thumb.aspect;
} else {
hx = hy * thumb.aspect;
}
own.image(
[
front + Vec3::new(-hx, hy, 0.0),
front + Vec3::new(hx, hy, 0.0),
front + Vec3::new(hx, -hy, 0.0),
front + Vec3::new(-hx, -hy, 0.0),
],
thumb.uv,
tint,
);
}
}
}
} else {
glyph(own, node.kind, layout.front_of(id));
}
for (other, relation, outgoing) in graph.relations(id) {
if !shown.contains(&other) {
continue;
}
let (dependent, source) = match outgoing {
true => (id, other),
false => (other, id),
};
let from = layout.position(dependent);
let to = layout.position(source);
let source_color = match relation {
Relation::DependsOn => Stroke::Depends.color(),
_ => {
let (r, g, b) = kind_rgb(graph.node(source).kind);
Color::srgb(r, g, b)
}
};
let colors = [DEPENDENT_END, source_color];
let drawn = match model.options.edges {
Edges::Selected => model.selected == Some(id),
Edges::All => outgoing,
Edges::None => false,
};
if !drawn {
continue;
}
match relation {
Relation::DependsOn => {
wires
.get_mut(&Stroke::Depends)
.expect("stroke")
.dashed_gradient(from, to, 12, colors);
}
Relation::Implements => {
wires
.get_mut(&Stroke::Implements)
.expect("stroke")
.dashed_gradient(from, to, 8, colors);
}
Relation::Uses => {
wires
.get_mut(&Stroke::Uses)
.expect("stroke")
.gradient(from, to, colors);
}
Relation::Contains => {}
}
}
if matches.contains(&id) {
polygon(
wires.get_mut(&Stroke::Match).expect("stroke"),
mark,
0.5,
12,
0.0,
);
}
let ring = |w: &mut Wires, radius: f32| {
let near = eye.distance(mark);
polygon(w, mark, radius * (near * 0.02).max(1.0), 12, 0.0);
};
if model.hovered == Some(id) {
ring(wires.get_mut(&Stroke::Highlight).expect("stroke"), 0.45);
}
if model.selected == Some(id) {
let highlight = wires.get_mut(&Stroke::Highlight).expect("stroke");
ring(highlight, 0.6);
highlight.line(mark, mark + Vec3::Y * 1.2);
}
}
for (stroke, entity) in &self.strokes {
if let Some(built) = wires.remove(stroke) {
app.edit::<Wires>(*entity, |wires| *wires = built);
}
}
}
fn draw_labels(&self, view: &View, visible: &[Id]) {
let Some(model) = &self.model else {
return;
};
let eye = view.camera.eye;
let mut labels = Vec::new();
for &id in visible {
let node = model.graph.node(id);
let position = model.anchor(id);
let Some((x, y)) = view.project_unclipped(position) else {
continue;
};
if !view.is_near(x, y, 240.0) {
continue;
}
let distance = eye.distance(position);
let options = &model.options;
let marked = model.selected == Some(id) || model.hovered == Some(id);
let size = model.layout.size_of(id);
let across = view
.project_unclipped(position + Vec3::X * (size.x - 0.8))
.map_or(0.0, |(right, _)| (right - x).abs());
let (px, text) = if node.kind.is_container() {
let per_unit = across / size.x.max(1.0);
let band = 0.9 * model.layout.header_of(id) * per_unit;
let px = (across * 0.035).min(band.max(8.0)).clamp(8.0, 18.0).round();
let room = ((across - 4.0) / (px * ui::ADVANCE_PER_EM)).max(0.0) as usize;
if (room < 4 || across < 40.0) && !marked {
continue;
}
let text = match marked {
true => node.name.clone(),
false => clip(&node.name, room),
};
(px, text)
} else {
let px = (13.0 * (options.label_range / distance).clamp(0.4, 1.2)).round();
(px, node.name.clone())
};
if px < options.label_floor && !marked {
continue;
}
let order = match (marked, node.kind.is_container()) {
(true, _) => -2.0,
(false, true) => -1.0 - across / 100_000.0,
(false, false) => distance,
};
labels.push((order, id, x, y, px.max(8.0), text));
}
labels.sort_by(|a, b| a.0.total_cmp(&b.0));
labels.truncate(model.options.max_labels);
let mut placed: Vec<(f32, f32, f32, f32)> = Vec::new();
for (order, id, x, y, px, text) in labels {
let node = model.graph.node(id);
let width = text.chars().count() as f32 * px * ui::ADVANCE_PER_EM;
let rect = match node.kind.is_container() {
true => (x, y, width, px + 4.0),
false => (x - width * 0.5, y - px - 6.0, width, px + 4.0),
};
let overlaps = placed.iter().any(|&(px0, py0, w0, h0)| {
rect.0 < px0 + w0
&& px0 < rect.0 + rect.2
&& rect.1 < py0 + h0
&& py0 < rect.1 + rect.3
});
if overlaps && order >= -1.0 {
continue;
}
placed.push(rect);
let color = if model.selected == Some(id) {
yakui_color(Stroke::Highlight.color())
} else if model.hovered == Some(id) {
yakui::geometry::Color::WHITE
} else if node.kind.is_container() {
yakui_color(Color::hsl(level_hue(model.graph.depth(id)), 0.9, 0.72))
} else {
kind_color(node.kind)
};
ui::place_px(UiVec2::new(rect.0, rect.1), || {
ui::text_colored(px, text, color);
});
}
}
fn draw_font_samples(&mut self, view: &View, visible: &[Id]) {
let Some(model) = &self.model else {
return;
};
for &id in visible {
let node = model.graph.node(id);
if node.kind != Kind::File || !thumbs::is_font(Path::new(&node.name)) {
continue;
}
let Some(path) = node.file.clone() else {
continue;
};
let size = model.layout.size_of(id);
let front = model.layout.front_of(id);
let top_left =
view.project_unclipped(front + Vec3::new(-size.x * 0.5, size.y * 0.5, 0.0));
let bottom_right =
view.project_unclipped(front + Vec3::new(size.x * 0.5, -size.y * 0.5, 0.0));
let (Some((x0, y0)), Some((x1, y1))) = (top_left, bottom_right) else {
continue;
};
let across = (x1 - x0).abs();
if across < 48.0 {
continue;
}
let registered = self.fonts.entry(path.clone()).or_insert_with(|| {
let bytes = std::fs::read(&path).ok()?;
let font =
fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()).ok()?;
let name = path.to_string_lossy().into_owned();
yakui::context::dom()
.get_global_or_init(yakui::font::Fonts::default)
.add(font, Some(&name));
Some(name)
});
let Some(name) = registered.clone() else {
continue;
};
let px = (across * 0.42).clamp(12.0, 240.0).round();
let mut style = ui::style(px);
style.font = yakui::font::FontName::new(name);
let width = 2.0 * px * 0.6;
let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
ui::place_px(UiVec2::new(cx - width * 0.5, cy - px * 0.62), || {
let mut sample = yakui::widgets::Text::new(px, "Ag");
sample.style = style;
sample.show();
});
}
}
fn draw_selected(&self, app: &AppState) -> (Option<Id>, bool) {
let Some(model) = &self.model else {
return (None, false);
};
let Some(id) = model.selected else {
return (None, false);
};
let graph = &model.graph;
let node = graph.node(id);
let mut jump = None;
let width = 440.0;
let response = ui::window(
node.kind.label().to_uppercase(),
UiVec2::new(app.width() - width - ui::SCREEN_MARGIN, 64.0),
width,
true,
|| {
let mut column = yakui::widgets::List::column();
column.main_axis_size = yakui::MainAxisSize::Min;
column.item_spacing = 2.0;
column.show(|| {
ui::text_colored(18.0, node.name.clone(), kind_color(node.kind));
ui::text(12.0, shorten(&node.path, 52));
if let Some(file) = &node.file {
let place = match node.line {
0 => file.display().to_string(),
line => format!("{}:{line}", file.display()),
};
ui::text(12.0, shorten(&place, 50));
}
let inside = graph.children(id).len();
let visibility = if node.public { "pub" } else { "private" };
ui::text(
12.0,
format!("{visibility} · {} lines · {inside} inside", node.lines),
);
for (other, relation, outgoing) in graph.relations(id).into_iter().take(14) {
let arrow = if outgoing { "→" } else { "←" };
let label =
format!("{arrow} {} {}", relation.label(), graph.node(other).path);
if row(&shorten(&label, 46)) {
jump = Some(other);
}
}
});
},
);
(jump, response.closed)
}
fn draw_search(&self) -> Option<Id> {
let model = self.model.as_ref()?;
let query = model.search.as_deref()?;
let matches = model.matches(query);
let mut jump = None;
ui::place(0.5, 0.06, Pivot::TOP_CENTER, || {
ui::rows(|| {
ui::text(20.0, format!("SEARCH {query}_"));
for &id in matches.iter().take(10) {
let node = model.graph.node(id);
let label = format!("{} {}", node.kind.label(), node.path);
if ui::menu_row(label, None, None).clicked {
jump = Some(id);
}
}
if matches.len() > 10 {
ui::text(12.0, format!("… {} more", matches.len() - 10));
}
});
});
jump
}
fn draw_title(&self) {
let under_bar = UiVec2::new(ui::SCREEN_MARGIN, ui::MENU_BAR_HEIGHT + ui::SCREEN_MARGIN);
ui::place_px(under_bar, || {
ui::panel(|| {
let mut column = yakui::widgets::List::column();
column.main_axis_size = yakui::MainAxisSize::Min;
column.show(|| match &self.model {
Some(model) => {
let graph = &model.graph;
ui::text(18.0, graph.node(graph.root()).name.to_uppercase());
let (crates, folders, files) = (
graph.count(Kind::Crate),
graph.count(Kind::Dir),
graph.count(Kind::File),
);
let symbols =
graph.len() - 1 - crates - folders - files - graph.count(Kind::Module);
ui::text(
12.0,
format!("{crates} crates · {folders} folders · {files} files · {symbols} symbols"),
);
if self.indexing.is_some() {
ui::text(12.0, "re-indexing…");
}
if let Some(error) = &self.error {
ui::text(12.0, format!("last index failed: {error}"));
}
}
None => {
ui::text(18.0, "CODECRAFT");
}
});
});
});
ui::place(0.0, 1.0, Pivot::BOTTOM_LEFT, || {
margin_panel(|| {
ui::text(
12.0,
"right-drag orbit · middle-drag pan · wheel zoom · WASD/EQ move · click select/open · double-click or F focus · / search · Home fit · Esc",
);
});
});
}
fn draw_loading(&self) {
ui::place(0.5, 0.5, Pivot::CENTER, || {
let mut column = yakui::widgets::List::column();
column.main_axis_size = yakui::MainAxisSize::Min;
column.cross_axis_alignment = yakui::CrossAxisAlignment::Center;
column.item_spacing = 12.0;
column.show(|| match &self.error {
Some(error) => {
ui::text(40.0, "COULD NOT INDEX");
ui::text(16.0, self.root.display().to_string());
ui::text_colored(14.0, error.clone(), yakui_color(Stroke::Match.color()));
}
None => {
ui::text(40.0, "INDEXING");
ui::text(16.0, self.root.display().to_string());
let bounce = (self.started.elapsed().as_secs_f32() * 2.0).sin() * 0.5 + 0.5;
ui::progress(bounce, UiVec2::new(400.0, 14.0));
}
});
});
}
}
fn row(label: &str) -> bool {
ui::clickable(Some(ui::palette::ROW), || {
yakui::pad(yakui::widgets::Pad::balanced(6.0, 2.0), || {
ui::text(13.0, label.to_string());
});
})
.clicked
}
fn clip(name: &str, room: usize) -> String {
let count = name.chars().count();
if count <= room || room < 2 {
return name.to_string();
}
let keep = room - 1;
let tail = (keep / 3).min(8);
let head = keep - tail;
let start: String = name.chars().take(head).collect();
let end: String = name.chars().skip(count - tail).collect();
format!("{start}…{end}")
}
fn shorten(text: &str, max: usize) -> String {
let count = text.chars().count();
if count <= max {
return text.to_string();
}
let tail: String = text.chars().skip(count + 1 - max).collect();
format!("…{tail}")
}
fn margin_panel<F: FnOnce()>(children: F) {
yakui::pad(yakui::widgets::Pad::all(ui::SCREEN_MARGIN), || {
ui::panel(children);
});
}
const LETTERS: &[(KeyCode, char)] = &[
(KeyCode::KeyA, 'a'),
(KeyCode::KeyB, 'b'),
(KeyCode::KeyC, 'c'),
(KeyCode::KeyD, 'd'),
(KeyCode::KeyE, 'e'),
(KeyCode::KeyF, 'f'),
(KeyCode::KeyG, 'g'),
(KeyCode::KeyH, 'h'),
(KeyCode::KeyI, 'i'),
(KeyCode::KeyJ, 'j'),
(KeyCode::KeyK, 'k'),
(KeyCode::KeyL, 'l'),
(KeyCode::KeyM, 'm'),
(KeyCode::KeyN, 'n'),
(KeyCode::KeyO, 'o'),
(KeyCode::KeyP, 'p'),
(KeyCode::KeyQ, 'q'),
(KeyCode::KeyR, 'r'),
(KeyCode::KeyS, 's'),
(KeyCode::KeyT, 't'),
(KeyCode::KeyU, 'u'),
(KeyCode::KeyV, 'v'),
(KeyCode::KeyW, 'w'),
(KeyCode::KeyX, 'x'),
(KeyCode::KeyY, 'y'),
(KeyCode::KeyZ, 'z'),
(KeyCode::Digit0, '0'),
(KeyCode::Digit1, '1'),
(KeyCode::Digit2, '2'),
(KeyCode::Digit3, '3'),
(KeyCode::Digit4, '4'),
(KeyCode::Digit5, '5'),
(KeyCode::Digit6, '6'),
(KeyCode::Digit7, '7'),
(KeyCode::Digit8, '8'),
(KeyCode::Digit9, '9'),
(KeyCode::Minus, '_'),
];
impl Scene for CodeScene {
fn setup(&mut self, app: &mut AppState) {
let rig = OrbitCamera::new(Vec3::ZERO, 20.0)
.pitch(0.5)
.range(1.0, 600.0);
self.move_speed = rig.move_speed;
self.rig = Some(app.spawn_entity(rig));
let mut camera = app.camera();
camera.near = 0.05;
camera.far = 5000.0;
app.set_camera(camera);
self.strokes = Stroke::ALL
.iter()
.map(|&stroke| (stroke, app.spawn_entity(Wires::new(stroke.color()))))
.collect();
self.frame_camera(app);
}
fn update(&mut self, app: &mut AppState) {
self.poll_indexing(app);
self.poll_watch();
self.tick_fly(app);
if self.model.is_none() {
ui::screen(|| self.draw_loading());
return;
}
self.handle_keys(app);
if let Some(model) = &mut self.model {
model.tick();
}
let uploads = self.thumbs.poll();
if !uploads.is_empty() {
app.ecs
.world
.resource_mut::<WireDrawList>()
.uploads
.extend(uploads);
}
let Some(view) = app.views().iter().next().copied() else {
return;
};
let eye = view.camera.eye;
let visible = self
.model
.as_ref()
.map(|m| m.visible(eye))
.unwrap_or_default();
self.handle_mouse(app, &view, &visible);
self.draw_wires(app, &view, &visible, eye);
let mut jump = None;
let mut close = false;
let mut action = None;
ui::screen(|| {
action = self.draw_menu();
self.draw_labels(&view, &visible);
self.draw_font_samples(&view, &visible);
self.draw_title();
let (wanted, closed) = self.draw_selected(app);
jump = wanted.or(self.draw_search());
close = closed;
});
if close && let Some(model) = &mut self.model {
model.selected = None;
}
if let Some(id) = jump {
self.set_searching(app, false);
self.jump(app, id);
}
if let Some(action) = action {
self.act(app, action);
}
}
}
#[cfg(test)]
mod option_tests {
use super::*;
#[test]
fn options_turn_round_and_reset() {
let mut options = Options::default();
let start = options.open_depth;
options.turn(Knob::Depth);
assert_ne!(options.open_depth, start);
assert!(!options.depth_label().is_empty());
for _ in 0..4 {
options.turn(Knob::Depth);
}
assert_eq!(options.open_depth, start, "five settings, round and back");
options.turn(Knob::Edges);
options.turn(Knob::Private);
assert_eq!(options.edges, Edges::All);
assert!(!options.private);
options.turn(Knob::Reset);
assert_eq!(options, Options::default());
}
#[test]
fn a_setting_outside_the_choices_goes_to_the_first() {
assert_eq!(next(7, &[1, 2, 3]), 1);
assert_eq!(next(3, &[1, 2, 3]), 1);
assert_eq!(next(1, &[1, 2, 3]), 2);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::code::graph::Node;
use crate::sceneobjects::cameras::Camera;
use crate::ui::Rect;
fn model() -> Model {
let mut graph = Graph::new("ws");
let engine = graph.add(graph.root(), Node::new(Kind::Crate, "engine"));
let net = graph.add(engine, Node::new(Kind::Module, "net"));
graph.add(net, Node::new(Kind::Struct, "Socket"));
graph.add(net, Node::new(Kind::Trait, "Listen"));
let game = graph.add(graph.root(), Node::new(Kind::Crate, "game"));
graph.add(game, Node::new(Kind::Fn, "main"));
graph.relate(game, engine, Relation::DependsOn);
let mut model = Model::new(graph);
model.options.open_depth = 1;
model
}
fn far() -> Vec3 {
Vec3::new(0.0, 100.0, 100.0)
}
#[test]
fn modules_start_shut_and_open_on_a_toggle() {
let mut model = model();
let net = model.graph.find("engine::net").unwrap();
let socket = model.graph.find("engine::net::Socket").unwrap();
let visible = model.visible(far());
assert!(visible.contains(&net));
assert!(!visible.contains(&socket));
model.toggle(net, far());
assert!(model.visible(far()).contains(&socket));
model.toggle(net, far());
assert!(!model.visible(far()).contains(&socket));
}
#[test]
fn a_module_opens_by_itself_when_the_eye_is_close() {
let model = model();
let net = model.graph.find("engine::net").unwrap();
let socket = model.graph.find("engine::net::Socket").unwrap();
let eye = model.layout.position(net) + Vec3::Y * 0.5;
assert!(model.visible(eye).contains(&socket));
}
#[test]
fn crates_start_open_and_can_be_shut() {
let mut model = model();
let game = model.graph.find("game").unwrap();
let main = model.graph.find("game::main").unwrap();
assert!(model.visible(far()).contains(&main));
model.toggle(game, far());
assert!(!model.visible(far()).contains(&main));
assert!(
model.visible(far()).contains(&game),
"the crate itself stays"
);
}
#[test]
fn reveal_opens_everything_above_a_node() {
let mut model = model();
let game = model.graph.find("game").unwrap();
model.toggle(game, far());
let socket = model.graph.find("engine::net::Socket").unwrap();
model.reveal(socket);
assert!(model.visible(far()).contains(&socket));
}
#[test]
fn search_is_case_insensitive_and_exact_names_come_first() {
let model = model();
let found = model.matches("SOCK");
assert_eq!(
found,
vec![model.graph.find("engine::net::Socket").unwrap()]
);
let found = model.matches("net");
assert_eq!(found[0], model.graph.find("engine::net").unwrap());
assert!(model.matches("").is_empty());
}
#[test]
fn picking_finds_the_node_under_the_cursor() {
let model = model();
let engine = model.graph.find("engine").unwrap();
let at = model.anchor(engine);
let camera = Camera::looking_at(at + Vec3::new(0.0, 6.0, 8.0), at);
let view = View::new(Rect::new(0.0, 0.0, 800.0, 600.0), camera);
let visible = model.visible(camera.eye);
let (x, y) = view.project(at).expect("on screen");
assert_eq!(
model.pick(&view, (x + 3.0, y - 2.0), &visible),
Some(engine)
);
assert_eq!(model.pick(&view, (x + 200.0, y), &visible), None);
}
#[test]
fn open_depth_and_private_options_change_what_is_visible() {
let mut model = model();
let socket = model.graph.find("engine::net::Socket").unwrap();
assert!(!model.visible(far()).contains(&socket));
model.options.open_depth = 2;
assert!(model.visible(far()).contains(&socket));
model.close_all();
assert!(
!model.visible(far()).contains(&socket),
"shut by hand beats the option"
);
model.open_all();
assert!(model.visible(far()).contains(&socket));
let mut graph = Graph::new("ws");
let krate = graph.add(graph.root(), Node::new(Kind::Crate, "k"));
let hidden = graph.add(krate, Node::new(Kind::Fn, "secret").visible(false));
let mut model = Model::new(graph);
assert!(model.visible(far()).contains(&hidden));
model.options.private = false;
assert!(!model.visible(far()).contains(&hidden));
}
#[test]
fn a_new_graph_keeps_the_selection_by_path() {
let mut model = model();
model.selected = model.graph.find("engine::net::Socket");
let mut graph = Graph::new("ws");
let engine = graph.add(graph.root(), Node::new(Kind::Crate, "engine"));
let net = graph.add(engine, Node::new(Kind::Module, "net"));
let socket = graph.add(net, Node::new(Kind::Struct, "Socket"));
model.retarget(graph);
assert_eq!(model.selected, Some(socket));
}
}
#[cfg(test)]
mod text_tests {
use super::shorten;
#[test]
fn shortening_keeps_the_end_of_a_path() {
assert_eq!(shorten("short", 10), "short");
assert_eq!(shorten("a::very::long::path::Name", 10), "…ath::Name");
}
}
#[cfg(test)]
mod clip_tests {
use super::clip;
#[test]
fn clipping_keeps_both_ends_of_a_name() {
assert_eq!(clip("short.png", 20), "short.png");
assert_eq!(
clip("Screenshot 2026-08-22 162926.png", 14),
"Screensho….png"
);
assert_eq!(clip("abcdefgh", 4), "ab…h");
}
}