dwarves-gui/src/front_tree.rs
//! **🌳 Tree — the front page of 🏠 Workspace**: the selected workspace's build/read
//! graph with live marks.
//!
//! Nothing is folded here. The tab asks the forge ONE surface, `Dwarves.Tree`, on the
//! code-room door it already speaks ([`crate::room`]); the forge folds the tree from
//! nornir's build board (`Coderoom.Units`) on its own jera lane and answers at once —
//! `queued` / `running` while the first fold runs (the ask is repeated until it is
//! there, off the UI thread), then the tree. While the tab is open it asks again every
//! [`REFRESH`]; a click on a repo or a crate drills in (asked as its own question).
//!
//! Drawn on the ONE graph renderer ([`crate::dag_graph::DagPane`], facett-graphview) —
//! the same engine the Release DAG and the build graph use. Marks: grey waiting ·
//! amber queued · pulsing blue compiling · glowing reading · green done · red failed.
//! A shared crate / unit carries `×N` (how many repos use it).
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use facett_core::Facet;
use facett_graphview::{Color, LayoutOpts};
use crate::dag_graph::{DagNode, DagPane, DagSpec};
use crate::room::{self, ForgeRoom, Pending, RoomAnswer, RoomSource};
/// The forge surface (answered by the forge itself, never forwarded to nornir).
pub const SURFACE: &str = "Dwarves.Tree";
/// How often an open tab asks again.
pub const REFRESH: Duration = Duration::from_secs(2);
/// The fewest rows a column of the tree holds before a rank is staggered (the repo
/// column sets it when it is taller).
pub const MIN_COLUMN_ROWS: usize = 8;
/// The fill of a mark.
pub fn mark_fill(state: &str) -> Color {
match state {
"waiting" => Color::rgb(118, 122, 132),
"queued" => Color::rgb(214, 160, 20),
"compiling" => Color::rgb(40, 104, 214),
"reading" => Color::rgb(70, 178, 210),
"done" => Color::rgb(46, 150, 72),
"failed" => Color::rgb(198, 40, 40),
_ => Color::rgb(90, 90, 90),
}
}
/// The ring of a mark at `t` seconds: compiling PULSES (blue, alpha follows a 1.2 s
/// sine), reading GLOWS (a steady bright ring), failed is ringed red; the rest none.
pub fn mark_ring(state: &str, t: f32) -> Option<Color> {
match state {
"compiling" => {
let a = 0.5 + 0.5 * (t * std::f32::consts::TAU / 1.2).sin();
Some(Color::rgba(90, 160, 255, (60.0 + 195.0 * a) as u8))
}
"reading" => Some(Color::rgba(190, 245, 255, 255)),
"failed" => Some(Color::rgb(255, 90, 90)),
_ => None,
}
}
/// The tree answer as a spec for the graph renderer, its rings at `t` seconds.
pub fn spec_of(body: &serde_json::Value, t: f32) -> DagSpec {
let nodes = body["nodes"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.map(|n| {
let state = n["state"].as_str().unwrap_or("");
DagNode {
id: n["id"].as_str().unwrap_or("?").to_string(),
label: n["label"].as_str().unwrap_or("?").to_string(),
fill: mark_fill(state),
ring: mark_ring(state, t),
badge: n["badge"]
.as_str()
.filter(|b| !b.is_empty())
.map(str::to_string),
}
})
.collect();
let edges = body["edges"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.filter_map(|e| Some((e[0].as_str()?.to_string(), e[1].as_str()?.to_string())))
.collect();
DagSpec {
nodes,
edges,
consumes: Vec::new(),
}
}
/// The repo node ids of a tree answer — the engine pins them to the first column.
pub fn repo_ids(body: &serde_json::Value) -> Vec<String> {
body["nodes"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.filter(|n| n["kind"] == "repo")
.filter_map(|n| n["id"].as_str().map(str::to_string))
.collect()
}
/// The header line of a tree answer: units done/total · speed · ETA · which box.
pub fn header_line(body: &serde_json::Value) -> String {
let h = &body["header"];
let (done, total) = (
h["done"].as_u64().unwrap_or(0),
h["total"].as_u64().unwrap_or(0),
);
let speed = match h["units_per_min"].as_f64() {
// seeded from the board's timestamps (no two readings yet): an estimate, said so
Some(r) if h["rate_source"] == "board" => format!("~{r:.0} units/min"),
Some(r) => format!("{r:.0} units/min"),
None => "no rate yet".to_string(),
};
let eta = match h["eta_s"].as_u64() {
Some(0) if done >= total && total > 0 => "all done".to_string(),
Some(s) if s >= 3600 => format!("ETA {}h{:02}m", s / 3600, (s % 3600) / 60),
Some(s) => format!("ETA {}m{:02}s", s / 60, s % 60),
None => "no ETA yet".to_string(),
};
let pct = if total > 0 {
format!(" ({:.0} %)", done as f64 * 100.0 / total as f64)
} else {
String::new()
};
format!(
"{done}/{total} units done{pct} · {speed} · {eta} · builds on {}",
h["box"].as_str().unwrap_or("?")
)
}
/// The tab.
pub struct FrontTree {
source: Arc<dyn RoomSource>,
/// The forge endpoint + token, when the source is the shipped one (re-made on a
/// workspace switch); `None` for an injected source.
forge: Option<(String, String)>,
workspace: String,
expand: BTreeSet<String>,
answer: RoomAnswer,
pending: Option<Pending<RoomAnswer>>,
landed: Option<Instant>,
pane: DagPane,
spec: DagSpec,
/// The node ids the graph holds now (a new set = lay out again).
shown: Vec<String>,
epoch: Instant,
}
impl FrontTree {
/// The shipped tab: asks the forge at `endpoint` about `workspace`.
pub fn new(endpoint: String, token: String, workspace: String) -> Self {
let source = ForgeRoom::shared(&endpoint, &token, &workspace);
let mut me = Self::with_source(source, &workspace);
me.forge = Some((endpoint, token));
me
}
/// A tab over an injected source (tests).
pub fn with_source(source: Arc<dyn RoomSource>, workspace: &str) -> Self {
let via = source.describe();
Self {
source,
forge: None,
workspace: workspace.to_string(),
expand: BTreeSet::new(),
answer: RoomAnswer::pending(SURFACE, &via),
pending: None,
landed: None,
pane: DagPane::new("Tree"),
spec: DagSpec::default(),
shown: Vec::new(),
epoch: Instant::now(),
}
}
/// Ask the forge (on a worker; the frame polls).
pub fn ask(&mut self) {
let source = Arc::clone(&self.source);
let params = serde_json::json!({ "expand": self.expand.iter().collect::<Vec<_>>() });
self.pending = Some(Pending::spawn("front-tree-ask", move || {
source.ask(SURFACE, ¶ms)
}));
}
fn land(&mut self, a: RoomAnswer) {
if a.is_measured() {
let t = self.epoch.elapsed().as_secs_f32();
self.spec = spec_of(&a.body, t);
let ids: Vec<String> = self.spec.nodes.iter().map(|n| n.id.clone()).collect();
let edges_now = self.spec.edges.len();
// the repos line up in the first column, what they use flows right; no
// column grows taller than the repo column (a taller rank is staggered)
let repos = repo_ids(&a.body);
let mut layout = LayoutOpts::roots_left(repos.iter().cloned());
layout.max_rows = Some(repos.len().max(MIN_COLUMN_ROWS));
self.pane.set_layout(layout);
if self.shown.is_empty() {
// the very first view: frame it (after that only ⊙ fit frames)
self.pane.set_spec_fit(&self.spec);
self.shown = ids;
} else if ids != self.shown || edges_now != self.pane.edge_count() {
// a drill in / out, a new plan: the camera stays, the clicked node keeps
// its spot (📌 Stay) or flies to the centre (🎯 Focus)
self.pane.set_spec_keep_view(&self.spec);
self.shown = ids;
} else {
// a plain refresh: the marks move, the user's camera stays
self.pane.restyle(&self.spec);
}
}
self.answer = a;
self.landed = Some(Instant::now());
}
/// Take a finished ask, if any.
pub fn poll(&mut self) {
let Some(p) = &self.pending else { return };
match p.try_take() {
None => {}
Some(Ok(a)) => {
self.pending = None;
self.land(a);
}
Some(Err(())) => {
self.pending = None;
let a = room::worker_died(SURFACE, &*self.source);
self.land(a);
}
}
}
/// Block until the ask in flight lands (robot seam / tests).
pub fn wait(&mut self) {
if self.pending.is_none() {
self.ask();
}
if let Some(p) = self.pending.take() {
let a = p
.wait()
.unwrap_or_else(|_| room::worker_died(SURFACE, &*self.source));
self.land(a);
}
}
/// Drill into / back out of a node (a repo shows its crates, a crate its units).
pub fn toggle(&mut self, id: &str) {
let expandable = self.answer.body["nodes"].as_array().is_some_and(|ns| {
ns.iter()
.any(|n| n["id"] == id && n["expandable"].as_bool() == Some(true))
});
if !expandable {
return;
}
if !self.expand.remove(id) {
self.expand.insert(id.to_string());
}
self.ask();
}
fn set_workspace(&mut self, ws: &str) {
if let Some((endpoint, token)) = &self.forge {
self.source = ForgeRoom::shared(endpoint, token, ws);
}
self.workspace = ws.to_string();
self.expand.clear();
self.pending = None;
self.answer = RoomAnswer::pending(SURFACE, &self.source.describe());
self.landed = None;
self.spec = DagSpec::default();
self.shown.clear();
self.pane = DagPane::new("Tree");
}
/// The node ids laid out in the leftmost column (the repos, when the engine pinned
/// them there).
fn first_column(&self) -> Vec<String> {
let pos = self.pane.positions();
let x0 = pos.iter().map(|(_, p)| p.x).fold(f32::INFINITY, f32::min);
pos.into_iter()
.filter(|(_, p)| (p.x - x0).abs() < 0.5)
.map(|(id, _)| id)
.collect()
}
/// Node counts by mark in the current answer.
fn marks(&self) -> std::collections::BTreeMap<String, u64> {
let mut m = std::collections::BTreeMap::new();
for n in self.answer.body["nodes"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[])
{
*m.entry(n["state"].as_str().unwrap_or("?").to_string())
.or_insert(0) += 1;
}
m
}
}
impl Facet for FrontTree {
fn title(&self) -> &str {
"Tree"
}
fn ui(&mut self, ui: &mut egui::Ui) {
self.poll();
let due = self.landed.is_none_or(|l| l.elapsed() >= REFRESH);
if self.pending.is_none() && due {
self.ask();
}
ui.horizontal_wrapped(|ui| {
ui.strong(format!(
"🌳 {}",
if self.workspace.is_empty() {
"(no workspace selected)"
} else {
&self.workspace
}
));
ui.separator();
if self.answer.is_measured() {
ui.label(header_line(&self.answer.body));
let age = self.answer.body["age_ms"].as_u64().unwrap_or(0);
if age > 0 {
ui.weak(format!("· as of {:.1}s ago", age as f32 / 1000.0));
}
}
if let Some(p) = &self.pending {
ui.spinner();
ui.weak(room::asking_line(p.started));
}
});
if self.answer.is_measured() {
let jobs = self.answer.body["header"]["jobs"]
.as_array()
.cloned()
.unwrap_or_default();
if !jobs.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.weak("jera:");
for j in &jobs {
ui.small(format!(
"{} {} ({})",
j["kind"].as_str().unwrap_or("?"),
j["target"].as_str().unwrap_or(""),
j["status"].as_str().unwrap_or("?")
));
}
});
}
ui.horizontal_wrapped(|ui| {
for s in dwarves::unit_tree::BUSY_ORDER {
let c = mark_fill(s);
ui.label(
egui::RichText::new(format!(" {s} "))
.small()
.color(egui::Color32::WHITE)
.background_color(egui::Color32::from_rgb(c.r, c.g, c.b)),
);
}
ui.weak(format!(
"· {} of {} crates shown — click a repo or a crate to drill in",
self.answer.body["crates_shown"], self.answer.body["crates_total"]
));
});
let ev = self.answer.evidence_text();
if !ev.is_empty() {
ui.label(egui::RichText::new(ev).small());
}
ui.separator();
// the live marks: re-ring every frame while something pulses
if self.spec.nodes.iter().any(|n| n.ring.is_some()) {
let t = self.epoch.elapsed().as_secs_f32();
for n in &mut self.spec.nodes {
if n.ring.is_some() {
let state = self.answer.body["nodes"]
.as_array()
.and_then(|ns| ns.iter().find(|x| x["id"] == n.id.as_str()))
.and_then(|x| x["state"].as_str())
.unwrap_or("");
n.ring = mark_ring(state, t);
}
}
self.pane.restyle(&self.spec);
ui.ctx().request_repaint_after(Duration::from_millis(60));
}
self.pane.ui(ui);
if let Some(id) = self.pane.take_click() {
self.toggle(&id);
}
} else {
room::callout(ui, "the tree", &self.answer);
}
ui.ctx().request_repaint_after(Duration::from_millis(250));
}
fn state_json(&self) -> serde_json::Value {
let b = &self.answer.body;
serde_json::json!({
"kind": "front_tree",
"workspace": self.workspace,
"answer": self.answer.state_json(),
"header": if self.answer.is_measured() { serde_json::Value::String(header_line(b)) } else { serde_json::Value::Null },
"done": b["header"]["done"],
"total": b["header"]["total"],
"units_per_min": b["header"]["units_per_min"],
"eta_s": b["header"]["eta_s"],
"box": b["header"]["box"],
"nodes": self.pane.node_count(),
"edges": self.pane.edge_count(),
"marks": self.marks(),
"expand": self.expand,
"pending": self.pending.is_some(),
"graph": self.pane.report(),
"first_column": self.first_column(),
// where the graph sits on screen vs the part of its canvas the viewer sees
// (a robot checks "centred, fully visible" on these)
"screen": {
"box": self.pane.screen_box().map(|b| [b.min.x, b.min.y, b.max.x, b.max.y]),
"visible": self.pane.last_visible().map(|b| [b.min.x, b.min.y, b.max.x, b.max.y]),
},
})
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
self.set_workspace(ws);
return;
}
if let Some(id) = v.get("toggle").and_then(|t| t.as_str()) {
self.toggle(id);
return;
}
if v.get("reread").and_then(|t| t.as_bool()) == Some(true) {
self.ask();
self.wait();
return;
}
if v.get("wait").and_then(|t| t.as_bool()) == Some(true) {
self.wait();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::room::testing::CannedRoom;
use serde_json::json;
fn tree() -> serde_json::Value {
json!({
"surface": SURFACE, "state": "complete", "workspace": "w",
"header": { "done": 30, "total": 120, "units_per_min": 45.0, "eta_s": 120, "box": "dwarf", "jobs": [] },
"nodes": [
{ "id": "repo:a", "kind": "repo", "label": "a", "state": "compiling", "badge": "compiling 10/60", "expandable": true },
{ "id": "repo:b", "kind": "repo", "label": "b", "state": "done", "badge": "landed 60/60", "expandable": true },
{ "id": "crate:serde", "kind": "crate", "label": "serde@1.0.0", "state": "reading", "shared_by": 2, "badge": "×2 · 2 units", "expandable": true },
],
"edges": [["repo:a", "crate:serde"], ["repo:b", "crate:serde"]],
"crates_total": 40, "crates_shown": 1,
"evidence": { "missing": [], "notes": [] },
})
}
/// The tab asks the forge `Dwarves.Tree` and draws its answer on the shared graph
/// renderer: every node, every edge, the header line, the marks.
#[test]
fn the_tab_draws_the_forges_tree() {
let src = CannedRoom::new(&[(SURFACE, tree())]);
let mut t = FrontTree::with_source(src.clone(), "w");
t.wait();
let s = t.state_json();
assert_eq!(
(s["nodes"].as_u64(), s["edges"].as_u64()),
(Some(3), Some(2)),
"{s}"
);
assert_eq!(
s["header"],
"30/120 units done (25 %) · 45 units/min · ETA 2m00s · builds on dwarf"
);
assert_eq!(
s["marks"],
json!({ "compiling": 1, "done": 1, "reading": 1 })
);
assert!(
s["graph"]["node_cap"].is_null(),
"the shared renderer: no cap"
);
assert_eq!(
src.asked.lock().unwrap()[0],
(SURFACE.to_string(), json!({ "expand": [] }))
);
}
/// A click drills in: the next ask names the node, and a second click backs out.
#[test]
fn a_click_drills_in_and_out() {
let src = CannedRoom::new(&[(SURFACE, tree())]);
let mut t = FrontTree::with_source(src.clone(), "w");
t.wait();
t.update_json(r#"{"toggle":"crate:serde"}"#);
t.wait();
assert_eq!(
src.asked.lock().unwrap().last().unwrap().1,
json!({ "expand": ["crate:serde"] })
);
t.update_json(r#"{"toggle":"crate:serde"}"#);
t.wait();
assert_eq!(
src.asked.lock().unwrap().last().unwrap().1,
json!({ "expand": [] })
);
t.update_json(r#"{"toggle":"nope"}"#);
assert_eq!(
t.state_json()["pending"],
false,
"an unknown node asks nothing"
);
}
/// A rate seeded from the board's timestamps reads as an estimate.
#[test]
fn a_board_seeded_rate_reads_as_an_estimate() {
let mut b = tree();
b["header"]["rate_source"] = json!("board");
assert!(
header_line(&b).contains("· ~45 units/min ·"),
"{}",
header_line(&b)
);
b["header"]["rate_source"] = json!("readings");
assert!(header_line(&b).contains("· 45 units/min ·"));
}
/// No plan yet / queued: the state and its sentence, no graph, no number.
#[test]
fn no_plan_yet_is_a_state_not_an_empty_graph() {
let src = CannedRoom::new(&[(
SURFACE,
json!({ "surface": SURFACE, "state": "empty_gap", "evidence": { "missing": [{ "input": "plan", "why": "no plan yet — the board fills when the server reads the workspace" }] } }),
)]);
let mut t = FrontTree::with_source(src, "w");
t.wait();
let s = t.state_json();
assert_eq!(s["answer"]["state"], "empty_gap");
assert!(s["answer"]["evidence"]
.as_str()
.unwrap()
.contains("no plan yet"));
assert!(s["header"].is_null() && s["nodes"] == 0, "{s}");
}
/// Three repos at different depths, a chain of shared crates under them.
fn deep_tree() -> serde_json::Value {
let n = |id: &str, kind: &str| json!({ "id": id, "kind": kind, "label": id, "state": "done", "badge": "", "expandable": true });
json!({
"surface": SURFACE, "state": "complete", "workspace": "w",
"header": { "done": 1, "total": 2, "units_per_min": null, "eta_s": null, "box": "dwarf", "jobs": [] },
"nodes": [n("repo:a", "repo"), n("repo:b", "repo"), n("repo:c", "repo"),
n("crate:x", "crate"), n("crate:y", "crate"), n("crate:z", "crate"), n("crate:w", "crate")],
"edges": [["repo:a", "crate:x"], ["crate:x", "crate:y"], ["crate:y", "crate:z"], ["crate:z", "crate:w"],
["repo:b", "crate:w"], ["repo:c", "crate:z"]],
"crates_total": 4, "crates_shown": 4,
"evidence": { "missing": [], "notes": [] },
})
}
/// Paint the tab `frames` times in a 1200 × 800 harness.
fn paint(t: FrontTree, frames: usize) -> FrontTree {
let cell = std::cell::RefCell::new(t);
{
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1200.0, 800.0))
.build_ui(|ui| cell.borrow_mut().ui(ui));
for _ in 0..frames {
h.step();
}
}
cell.into_inner()
}
/// **The first view is framed, the repos in column 0.** Rendered: every repo sits
/// at the same (leftmost) x, every crate right of it; every node lands inside the
/// canvas and the graph's box fills it in one direction (zoomed to fit, not off to
/// the right at zoom 1). A plain refresh keeps the user's camera, and so does a new
/// shape (a drill-in): only the first view and ⊙ fit frame.
#[test]
fn the_first_view_is_framed_with_the_repos_in_the_first_column() {
let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
let mut t = FrontTree::with_source(src.clone(), "w");
t.wait();
let t = paint(t, 4);
let pos: std::collections::BTreeMap<String, facett_graphview::Pos> =
t.pane.positions().into_iter().collect();
let repo_x: BTreeSet<i64> = ["repo:a", "repo:b", "repo:c"]
.iter()
.map(|r| pos[*r].x as i64)
.collect();
assert_eq!(repo_x.len(), 1, "the repos share one column: {pos:?}");
let x0 = *repo_x.iter().next().unwrap() as f32;
assert!(
pos.iter()
.filter(|(id, _)| id.starts_with("crate:"))
.all(|(_, p)| p.x > x0),
"crates flow right of the repos: {pos:?}"
);
assert!(
pos.values().all(|p| p.x >= x0),
"column 0 is the leftmost: {pos:?}"
);
let rect = t.pane.last_rect().expect("painted");
assert!(!t.pane.fit_pending(), "the fit ran");
assert!(rect.height() >= 0.6 * 800.0, "the graph covers most of the tab's height: {rect:?}");
let screen: Vec<egui::Pos2> = pos
.values()
.map(|p| t.pane.world_to_screen(*p).unwrap())
.collect();
assert!(
screen.iter().all(|p| rect.contains(*p)),
"every node on the canvas {rect:?}: {screen:?}"
);
let (min_x, max_x) = screen
.iter()
.fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)));
let (min_y, max_y) = screen
.iter()
.fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert!(
(max_x - min_x) / rect.width() > 0.6 || (max_y - min_y) / rect.height() > 0.6,
"fitted: box {min_x}..{max_x} × {min_y}..{max_y} in {rect:?}"
);
assert!(
((min_x + max_x) * 0.5 - rect.center().x).abs() < rect.width() * 0.1,
"centred, not off to the right"
);
// a plain refresh keeps the user's camera
let mut t = t;
t.pane.set_camera(1.7, 33.0, -12.0);
t.update_json(r#"{"reread":true}"#);
let t = paint(t, 2);
assert_eq!(
t.pane.camera(),
(1.7, 33.0, -12.0),
"a refresh keeps the camera"
);
// a new shape (a drill-in, a new plan) KEEPS the camera: only the first view
// and ⊙ fit frame
let mut t = t;
let mut drilled = deep_tree();
drilled["nodes"].as_array_mut().unwrap().push(json!({ "id": "crate:v", "kind": "crate", "label": "v", "state": "done", "badge": "", "expandable": true }));
drilled["edges"].as_array_mut().unwrap().push(json!(["repo:a", "crate:v"]));
src.set(SURFACE, drilled);
t.update_json(r#"{"reread":true}"#);
let t = paint(t, 2);
assert_eq!(t.pane.camera(), (1.7, 33.0, -12.0), "a new shape keeps the camera");
}
/// The tab in a live harness: frames at 60 fps, real pointer events.
struct Live {
h: egui_kittest::Harness<'static>,
tab: std::rc::Rc<std::cell::RefCell<FrontTree>>,
}
impl Live {
fn new(t: FrontTree) -> Self {
let tab = std::rc::Rc::new(std::cell::RefCell::new(t));
let t2 = tab.clone();
let h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui(move |ui| t2.borrow_mut().ui(ui));
let mut me = Self { h, tab };
me.steps(4);
me
}
fn steps(&mut self, n: usize) {
for _ in 0..n {
self.h.step();
}
}
/// Frames until the ask in flight landed and the camera stopped flying.
fn settle(&mut self) {
for _ in 0..400 {
self.h.step();
let t = self.tab.borrow();
if t.pending.is_none() && !t.pane.flying() {
break;
}
drop(t);
std::thread::sleep(Duration::from_millis(2));
}
self.steps(2);
}
fn screen_of(&self, id: &str) -> egui::Pos2 {
let t = self.tab.borrow();
let p = t.pane.positions().into_iter().find(|(i, _)| i == id).unwrap().1;
t.pane.world_to_screen(p).unwrap()
}
fn click(&mut self, p: egui::Pos2) {
self.h.input_mut().events.push(egui::Event::PointerMoved(p));
self.h.step();
let b = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
self.h.input_mut().events.push(b(true));
self.h.step();
self.h.input_mut().events.push(b(false));
self.h.step();
}
}
/// The drilled-in answer for repo:c: its own crates join, and the layout moves.
fn drilled_c() -> serde_json::Value {
let mut d = deep_tree();
for v in ["crate:c1", "crate:c2"] {
d["nodes"].as_array_mut().unwrap().push(json!({ "id": v, "kind": "crate", "label": v, "state": "done", "badge": "", "expandable": true }));
d["edges"].as_array_mut().unwrap().push(json!(["repo:c", v]));
}
d["nodes"].as_array_mut().unwrap().insert(0, json!({ "id": "repo:0", "kind": "repo", "label": "0", "state": "done", "badge": "", "expandable": true }));
d
}
/// **📌 Stay: a click never moves the view.** Rendered: a real click on repo:c drills
/// in (the forge answers a bigger tree, the layout shifts) — repo:c stays EXACTLY
/// where it was on screen and the zoom is untouched. RED before: the drill-in
/// refitted the whole graph.
#[test]
fn stay_a_click_keeps_the_clicked_node_where_it_was() {
let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
let mut t = FrontTree::with_source(src.clone(), "w");
t.wait();
let mut live = Live::new(t);
assert_eq!(live.tab.borrow().pane.nav_mode(), facett_core::fly::NavMode::Stay, "Stay by default");
let zoom = live.tab.borrow().pane.camera().0;
let before = live.screen_of("repo:c");
src.set(SURFACE, drilled_c());
live.click(before);
live.settle();
let t = live.tab.borrow();
assert_eq!(t.expand.iter().cloned().collect::<Vec<_>>(), vec!["repo:c".to_string()], "the click drilled in");
assert_eq!(t.pane.node_count(), 10, "the bigger tree landed");
drop(t);
let after = live.screen_of("repo:c");
assert!((after - before).length() < 0.5, "repo:c stayed put: {before:?} → {after:?}");
assert_eq!(live.tab.borrow().pane.camera().0, zoom, "the zoom did not move");
}
/// **🎯 Focus: a click flies the node to the centre.** Rendered: after the click (and
/// the drill-in landing) repo:c's screen centre is the canvas centre (±2 px) at the
/// focus zoom — the readable zoom, or less so repo:c's children fit around it.
#[test]
fn focus_a_click_flies_the_node_to_the_canvas_centre() {
let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
let mut t = FrontTree::with_source(src.clone(), "w");
t.wait();
let mut live = Live::new(t);
live.tab.borrow_mut().pane.set_nav_mode(facett_core::fly::NavMode::Focus);
let before = live.screen_of("repo:c");
src.set(SURFACE, drilled_c());
live.click(before);
live.settle();
let c = live.tab.borrow().pane.last_rect().unwrap().center();
let at = live.screen_of("repo:c");
assert!((at - c).length() <= 2.0, "repo:c at the canvas centre: {at:?} vs {c:?}");
let want = live.tab.borrow().pane.focus_zoom("repo:c").unwrap();
assert!(want <= facett_graphview::decorated_view::FOCUS_ZOOM, "readable at most");
assert!((live.tab.borrow().pane.camera().0 - want).abs() < 1e-3, "at the focus zoom {want} (repo:c and its children framed): {:?}", live.tab.borrow().pane.camera());
}
/// **⊙ fit is centred**: the toolbar button flies to the whole graph with its box
/// centre on the canvas centre (±2 px) — not world (0,0) at the centre, the graph
/// hanging off to the right.
#[test]
fn fit_centres_the_whole_graph() {
let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
let mut t = FrontTree::with_source(src.clone(), "w");
t.wait();
let mut live = Live::new(t);
live.tab.borrow_mut().pane.set_camera(1.0, 0.0, 0.0);
live.steps(2);
use egui_kittest::kittest::Queryable;
live.h.get_by_label("⊙ fit").click();
live.settle();
let t = live.tab.borrow();
let rect = t.pane.last_rect().unwrap();
let pts: Vec<egui::Pos2> = t.pane.positions().into_iter().map(|(_, p)| t.pane.world_to_screen(p).unwrap()).collect();
let (min, max) = pts.iter().fold((egui::pos2(f32::MAX, f32::MAX), egui::pos2(f32::MIN, f32::MIN)), |(a, b), p| (a.min(*p), b.max(*p)));
let mid = egui::pos2((min.x + max.x) * 0.5, (min.y + max.y) * 0.5);
assert!((mid - rect.center()).length() <= 2.0, "the graph's box is centred: {mid:?} vs {:?}", rect.center());
}
/// The marks: six fills, compiling pulses (its ring moves with time), reading glows.
#[test]
fn compiling_pulses_and_reading_glows() {
let fills: BTreeSet<(u8, u8, u8)> = dwarves::unit_tree::BUSY_ORDER
.iter()
.map(|s| {
let c = mark_fill(s);
(c.r, c.g, c.b)
})
.collect();
assert_eq!(fills.len(), 6, "six distinct marks");
let (a, b) = (
mark_ring("compiling", 0.0).unwrap(),
mark_ring("compiling", 0.3).unwrap(),
);
assert_ne!(a.a, b.a, "the compiling ring pulses");
assert_eq!(
mark_ring("reading", 0.0),
mark_ring("reading", 0.7),
"reading glows steadily"
);
assert!(mark_ring("done", 0.0).is_none() && mark_ring("queued", 0.0).is_none());
}
}