facett-demo/src/falkor_tab.rs
//! **The `falkor` tab** — a live **FalkorDB graph-DB browser** pane, the demo
//! consumer that wires the two halves of the graph-DB IDE together:
//!
//! * the **data half** — [`FalkorBrowser`](facett_graphview::FalkorBrowser), the
//! egui-free view-model that turns a Cypher query into a force-laid-out
//! [`GraphModel`](facett_graphview::GraphModel); and
//! * the **view half** — [`DecoratedGraphView`], the interactive pan/zoom/select
//! egui pane that paints that `GraphModel`.
//!
//! `facett-graphview`'s `falkor.rs` deliberately deferred this pane (commit
//! `7cc4904`) to avoid a multi-agent collision; this is that pane.
//!
//! ## No live redis
//! The browser talks to a database only through the
//! [`CypherRunner`](facett_graphview::CypherRunner) transport seam. This tab
//! supplies a **stubbed, in-memory** [`CannedRunner`] ([`demo_runner`]) that
//! returns a fixed nordisk-constellation [`CypherResult`] — so the pane opens on a
//! real laid-out graph with **zero** network, no `redis`, no `GRAPH.QUERY`. The
//! *shape* is exactly what a live FalkorDB run would hand back; only the transport
//! is swapped. Re-running the query (the **Run** button) round-trips the editable
//! Cypher text through the browser to the runner, proving the seam.
//!
//! ## Robot-tested
//! The kittest robot test (bottom of this file) renders the pane headless, asserts
//! it drew real nodes + edges, then synthesises a real pointer **click** on a node
//! (via [`DecoratedGraphView::world_to_screen`]) and asserts the click **selects**
//! that node — the same effect a user's mouse produces.
use facett::Facet;
use facett::core::FacetCaps;
use facett_graphview::{
CannedRunner, CypherEdge, CypherNode, CypherResult, DecoratedGraphView, FalkorBrowser,
};
/// The stable tab title (the deck / discovery-contract / LEGEND key).
pub const TITLE: &str = "falkor";
/// A FalkorDB graph-DB browser pane: a [`FalkorBrowser`] (Cypher → laid-out
/// [`GraphModel`](facett_graphview::GraphModel)) feeding an interactive
/// [`DecoratedGraphView`], driven by an in-memory [`CannedRunner`] (no redis).
pub struct FalkorTab {
title: &'static str,
/// The data half — endpoint, editable query, status, the laid-out model.
browser: FalkorBrowser,
/// The view half — paints `browser.model()`, owns pan/zoom + click-select.
view: DecoratedGraphView,
/// The stubbed, in-memory executor: no `redis`, no network. The **Run** button
/// re-runs the current query through it, proving the [`CypherRunner`] seam.
runner: CannedRunner,
/// The editable Cypher text buffer bound to the query field.
query_buf: String,
/// The viewport the graph was last framed for — re-used to frame the GPU layout
/// when it lands (the seed spiral was framed first).
frame_viewport: Option<egui::Vec2>,
}
impl FalkorTab {
/// A populated browser: build over the stubbed runner and immediately run the
/// canned query so the pane opens on a real, force-laid-out graph (no redis).
pub fn new(title: &'static str) -> Self {
let mut browser = FalkorBrowser::default();
browser.update(facett_graphview::falkor::Msg::SetQuery(DEMO_QUERY.to_string()));
let mut runner = demo_runner();
browser.run_query(&mut runner);
let view = Self::view_of(title, &browser);
let query_buf = browser.query().to_string();
Self { title, browser, view, runner, query_buf, frame_viewport: None }
}
/// The painter for `browser`'s current model: the shared GPU cloud opted in (the
/// RESIDENT lane above `cloud::RESIDENT_ABOVE` nodes — the model uploaded once, the
/// camera per frame), the CPU painter when no renderer is installed.
fn view_of(title: &'static str, browser: &FalkorBrowser) -> DecoratedGraphView {
DecoratedGraphView::new(title).with_model(browser.model().clone()).with_gpu(true)
}
/// Advance the browser's GPU layout job; when the settled positions land, re-seed
/// the view with them (ONE model change → one resident upload) and re-frame.
fn poll_layout(&mut self) {
if self.browser.poll_layout() {
self.view = Self::view_of(self.title, &self.browser);
if let Some(vp) = self.frame_viewport {
self.frame_graph(vp);
}
}
}
/// A browser whose in-memory runner answers `query` with `result` (the size +
/// generator control's Cypher-shaped graph): the query runs through the browser's
/// own path — the same layout a live FalkorDB answer gets — and the graph is framed
/// in `viewport`.
pub fn with_result(title: &'static str, query: &str, result: CypherResult, viewport: egui::Vec2) -> Self {
let mut browser = FalkorBrowser::default();
browser.update(facett_graphview::falkor::Msg::SetQuery(query.to_string()));
let mut runner = facett_graphview::CannedRunner::ok(result);
browser.run_query(&mut runner);
let view = Self::view_of(title, &browser);
let query_buf = browser.query().to_string();
let mut tab = Self { title, browser, view, runner, query_buf, frame_viewport: None };
tab.frame_graph(viewport);
tab
}
/// An **empty** browser (idle, no query run) — the empty-data-state twin the
/// mega-matrix coverage cell renders. Same stubbed runner, no result yet.
pub fn empty(title: &'static str) -> Self {
let browser = FalkorBrowser::default();
let view = DecoratedGraphView::new(title);
let query_buf = browser.query().to_string();
Self { title, browser, view, runner: demo_runner(), query_buf, frame_viewport: None }
}
/// The node / edge counts of the currently painted graph.
pub fn node_count(&self) -> usize {
self.view.state().model.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.view.state().model.edges.len()
}
/// The selected node id, if any (the interactive selection lives in the view).
pub fn selected(&self) -> Option<&str> {
self.view.state().selected.as_deref()
}
/// The screen-space position a node id is painted at, for out-of-band
/// hit-testing (the robot click seam). `None` before the first paint or for an
/// unknown id. Delegates to the view's recorded projection.
pub fn node_screen_pos(&self, id: &str) -> Option<egui::Pos2> {
let node = self.view.state().model.nodes.iter().find(|n| n.id == id)?;
self.view.world_to_screen(node.pos)
}
/// Run the current query text through the stubbed runner and re-seed the view
/// with the freshly laid-out model. The one I/O-shaped step (still no network —
/// the runner is in-memory). Selection resets with the new layout.
fn run(&mut self) {
self.browser
.update(facett_graphview::falkor::Msg::SetQuery(self.query_buf.clone()));
self.browser.run_query(&mut self.runner);
self.view = Self::view_of(self.title, &self.browser);
}
/// **Frame the whole graph in `viewport`** — the ⊙ Frame button. Delegates to
/// [`DecoratedGraphView::fit_model`]; a no-op on an empty result, which is why the
/// idle tab's camera stays at zoom 1.
fn frame_graph(&mut self, viewport: egui::Vec2) {
self.frame_viewport = Some(viewport);
self.view.fit_model(viewport, 12.0);
}
/// Run the GPU layout job to completion NOW (native tests with a device; a host
/// polls once per frame instead). `false` when no job was in flight.
pub fn finish_layout(&mut self) -> bool {
if !self.browser.layout_pending() {
return false;
}
while self.browser.layout_pending() {
self.poll_layout();
}
true
}
/// The browser's layout readout (`lane` / `pending` / `kernel` / `note`).
pub fn layout_json(&self) -> serde_json::Value {
self.browser.layout_json()
}
/// The view's last-frame lane readout (`cloud_lane`, `resident_uploads`, …).
pub fn legibility(&self) -> facett_graphview::legible::ViewReport {
self.view.legibility()
}
/// The camera the pane is currently looking through — `(zoom, pan_x, pan_y)`,
/// published so a driver can assert that ⊙ Frame moved it.
fn camera(&self) -> (f32, f32, f32) {
let st = self.view.state();
(st.zoom, st.pan_x, st.pan_y)
}
/// Clear the result graph back to idle (keeps the editable query text).
fn clear(&mut self) {
self.browser.update(facett_graphview::falkor::Msg::Clear);
self.view = DecoratedGraphView::new(self.title);
}
}
impl Facet for FalkorTab {
fn title(&self) -> &str {
self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
// The GPU layout job advances one bounded slice per frame (it never waits on
// the GPU), so the pane keeps repainting until it lands.
self.poll_layout();
if self.browser.layout_pending() {
ui.ctx().request_repaint();
}
let th = facett::core::theme(ui);
// ── the query bar: endpoint · editable Cypher · Run / Clear · status ──────
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(format!("FalkorDB · {}", self.browser.endpoint().display()))
.color(th.text_dim)
.strong(),
);
ui.separator();
ui.label(egui::RichText::new("in-memory runner · no redis").color(th.text_dim).italics());
});
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("Cypher").color(th.text));
let field = ui.add(
egui::TextEdit::singleline(&mut self.query_buf)
.desired_width(360.0)
.hint_text("MATCH (n)-[r]->(m) RETURN n, r, m"),
);
let run = ui.button("▶ Run").clicked();
let clear = ui.button("✕ Clear").clicked();
// ⊙ FRAME — `DecoratedGraphView::fit_model`, the bounds fit the engine gained
// when korp found it had none. The plain `Fit` recentres at zoom 1 and the
// interactive zoom floor is 0.25, so a laid-out graph wider than ~4x the
// widget could not be looked at from a host at all. Wired here because a
// capability that is not in the demo is invisible.
let fit = ui
.button("⊙ Frame")
.on_hover_text("Zoom + pan so the WHOLE graph fits this pane (fit_model, not the zoom-1 recentre)")
.clicked();
// Enter in the field also runs the query.
if run || (field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
self.run();
}
if clear {
self.clear();
}
if fit {
self.frame_graph(ui.available_size());
}
});
// status line (fail-loud on error).
let (status_txt, status_col) = match self.browser.status() {
facett_graphview::BrowserStatus::Idle => ("idle — run a query".to_string(), th.text_dim),
facett_graphview::BrowserStatus::Ok { nodes, edges } => {
(format!("ok · {nodes} nodes · {edges} edges"), th.text)
}
facett_graphview::BrowserStatus::Error { message } => {
(format!("error · {message}"), egui::Color32::from_rgb(230, 90, 90))
}
};
let lane = match (self.browser.layout_lane(), self.browser.layout_pending()) {
("gpu-job", true) => format!(" · layout on the GPU ({}) …", self.browser.layout_kernel()),
("gpu-job", false) => format!(" · layout: GPU ({})", self.browser.layout_kernel()),
("no GPU", _) => " · no GPU: the layout needs one at this size (seed spiral shown)".to_string(),
("cpu", _) => " · layout: CPU".to_string(),
_ => String::new(),
};
ui.label(egui::RichText::new(format!("{status_txt}{lane}")).color(status_col));
ui.separator();
// ── the graph canvas: delegate to the DecoratedGraphView (pan/zoom + the
// click-to-select the robot test drives). It records its paint rect so
// `node_screen_pos` can map a node back to the screen. ──────────────────
Facet::ui(&mut self.view, ui);
}
fn state_json(&self) -> serde_json::Value {
let status = match self.browser.status() {
facett_graphview::BrowserStatus::Idle => "idle",
facett_graphview::BrowserStatus::Ok { .. } => "ok",
facett_graphview::BrowserStatus::Error { .. } => "error",
};
serde_json::json!({
"component": "FalkorBrowserTab",
"endpoint": self.browser.endpoint().display(),
"graph": self.browser.endpoint().graph,
"query": self.browser.query(),
"status": status,
"node_count": self.node_count(),
"edge_count": self.edge_count(),
"labels": self.browser.labels(),
"label_count": self.browser.labels().len(),
"selected": self.selected(),
"navigable": true,
// The camera, so a driver asserts ⊙ Frame actually moved it rather than
// trusting a click landed. A framed graph is at a zoom below 1.
"camera": { "zoom": self.camera().0, "pan_x": self.camera().1, "pan_y": self.camera().2 },
"framed": self.camera().0 != 1.0,
// The transport is the in-memory seam — proof there is NO live redis.
"runner": "in-memory CannedRunner (no redis)",
// Which lane laid the answer out (cpu / gpu-job / no GPU) and which painted
// it (the view's cloud lane + its resident uploads).
"layout": self.browser.layout_json(),
"legibility": self.view.legibility().state_json(),
})
}
fn selection_json(&self) -> serde_json::Value {
Facet::selection_json(&self.view)
}
fn caps(&self) -> FacetCaps {
// Themeable + resizable + selectable (click a node) + navigable (pan/zoom) —
// the same surface the embedded DecoratedGraphView advertises.
FacetCaps::NONE.themeable().resizable().selectable().navigable()
}
}
/// The default Cypher the tab opens on (the "show me the release graph" query the
/// stubbed runner answers with [`demo_result`]).
pub const DEMO_QUERY: &str = "MATCH (r:Repo)-[:HAS]->(c:Crate) RETURN r, c";
/// The **stubbed, in-memory** [`CypherRunner`](facett_graphview::CypherRunner): a
/// [`CannedRunner`] that answers every query with the fixed [`demo_result`]
/// constellation. This is the whole "no live redis" story — swap it for a real
/// redis-backed runner at the consumer (korp/dwarves) and the pane is unchanged.
pub fn demo_runner() -> CannedRunner {
CannedRunner::ok(demo_result())
}
/// A canned nordisk-constellation [`CypherResult`] — three repos, their crates, and
/// cross-crate dependencies — the exact record shape a live FalkorDB `GRAPH.QUERY`
/// would parse to. Two labels (`Repo`, `Crate`) → two categorical colour slots.
pub fn demo_result() -> CypherResult {
CypherResult {
nodes: vec![
CypherNode::new(1, "Repo", "facett"),
CypherNode::new(2, "Repo", "nornir"),
CypherNode::new(3, "Repo", "dwarves"),
CypherNode::new(4, "Crate", "facett-core"),
CypherNode::new(5, "Crate", "facett-graphview"),
CypherNode::new(6, "Crate", "nornir-viz"),
CypherNode::new(7, "Crate", "dwarves-release"),
],
edges: vec![
CypherEdge::new(1, 4, "HAS"),
CypherEdge::new(1, 5, "HAS"),
CypherEdge::new(2, 6, "HAS"),
CypherEdge::new(3, 7, "HAS"),
CypherEdge::new(5, 4, "DEPENDS_ON"),
CypherEdge::new(6, 5, "DEPENDS_ON"),
CypherEdge::new(7, 6, "DEPENDS_ON"),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The demo constellation lays out to a real graph the tab paints: 7 nodes, 7
/// edges, two categorical labels — and the empty twin is genuinely empty.
#[test]
fn populated_and_empty_states_report_real_counts() {
let tab = FalkorTab::new(TITLE);
let s = tab.state_json();
assert_eq!(s["component"], "FalkorBrowserTab");
assert_eq!(s["node_count"], 7, "the demo constellation");
assert_eq!(s["edge_count"], 7);
assert_eq!(s["status"], "ok");
assert_eq!(s["label_count"], 2, "Repo + Crate");
assert_eq!(s["selected"], serde_json::Value::Null);
assert_eq!(s["runner"], "in-memory CannedRunner (no redis)");
let empty = FalkorTab::empty(TITLE);
let e = empty.state_json();
assert_eq!(e["node_count"], 0, "empty state paints no graph");
assert_eq!(e["edge_count"], 0);
assert_eq!(e["status"], "idle");
}
/// The stubbed runner answers the query with NO server — the round-trip proof:
/// after a run the runner recorded the exact (graph, query) the browser holds.
#[test]
fn query_round_trips_through_the_in_memory_runner() {
let mut runner = demo_runner();
let mut browser = FalkorBrowser::default();
browser.update(facett_graphview::falkor::Msg::SetQuery(DEMO_QUERY.into()));
browser.run_query(&mut runner);
let (graph, query) = runner.last.as_ref().expect("the runner was invoked with no redis");
assert_eq!(query, DEMO_QUERY, "the Cypher text routed verbatim to the runner");
assert_eq!(graph, &browser.endpoint().graph, "the graph key routed");
}
/// ROBOT-UI (kittest): render the pane headless, assert it drew real nodes +
/// edges, then synthesise a real pointer **click** on a node and assert the
/// click **selects** it — RED-when-broken if the pane stops rendering the graph
/// or the click no longer resolves to a node.
#[test]
fn robot_renders_nodes_edges_and_a_click_selects_a_node() {
use std::cell::RefCell;
use std::rc::Rc;
use egui_kittest::Harness;
let tab = Rc::new(RefCell::new(FalkorTab::new(TITLE)));
// Build a headless harness over the pane's own `ui`.
let t2 = tab.clone();
let mut h = Harness::builder()
.with_size(egui::vec2(900.0, 700.0))
.build_ui(move |ui| {
t2.borrow_mut().ui(ui);
});
// Let layout settle so the canvas rect is allocated + recorded.
h.run();
// ── renders nodes + edges ────────────────────────────────────────────────
{
let s = tab.borrow().state_json();
assert_eq!(s["node_count"], 7, "the pane rendered the 7-node constellation");
assert_eq!(s["edge_count"], 7, "the pane rendered its edges");
assert_eq!(s["selected"], serde_json::Value::Null, "nothing selected before the click");
}
// Pick the node closest to the world origin — it lands nearest the canvas
// centre, so its projected screen position is guaranteed inside the canvas.
let (target_id, click_pos) = {
let t = tab.borrow();
let model = &t.view.state().model;
let node = model
.nodes
.iter()
.min_by(|a, b| {
let da = a.pos.x * a.pos.x + a.pos.y * a.pos.y;
let db = b.pos.x * b.pos.x + b.pos.y * b.pos.y;
da.partial_cmp(&db).unwrap()
})
.expect("a populated graph");
let pos = t.node_screen_pos(&node.id).expect("the canvas painted → node has a screen pos");
(node.id.clone(), pos)
};
// ── synthesise a real pointer click on that node ─────────────────────────
h.event(egui::Event::PointerMoved(click_pos));
h.run();
h.event(egui::Event::PointerButton {
pos: click_pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
h.event(egui::Event::PointerButton {
pos: click_pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.run();
// ── the click selected the clicked node ──────────────────────────────────
let s = tab.borrow().state_json();
assert_eq!(
s["selected"].as_str(),
Some(target_id.as_str()),
"a click on node {target_id} selected it (got {:?})",
s["selected"]
);
}
/// **⊙ Frame really frames** — the demo of `DecoratedGraphView::fit_model`, the
/// bounds fit the engine gained in facett `019fbab7` when korp found it had none.
///
/// The pane's model is the demo constellation; the button must move the camera to a
/// zoom that puts EVERY node inside the pane. The assertion is on projected node
/// positions, not on the zoom scalar, because a zoom that changed is not the same
/// claim as a graph you can see.
///
/// RED (seen): replace `fit_model(viewport, 12.0)` in `frame_graph` with the plain
/// `self.view.fit()` and this fails at `⊙ Frame left the camera at zoom 1 — that is
/// the recentre, not a frame`.
#[test]
fn frame_button_puts_every_node_inside_the_pane() {
let mut tab = FalkorTab::new(TITLE);
assert_eq!(tab.camera(), (1.0, 0.0, 0.0), "an unframed pane starts at zoom 1, centred");
assert_eq!(tab.state_json()["framed"], false);
// A pane far smaller than the laid-out graph — the case a zoom-1 recentre cannot
// answer at all.
let viewport = egui::vec2(220.0, 160.0);
tab.frame_graph(viewport);
let (zoom, pan_x, pan_y) = tab.camera();
assert!(
zoom != 1.0,
"⊙ Frame left the camera at zoom {zoom} — that is the recentre, not a frame"
);
assert_eq!(tab.state_json()["framed"], true, "the state block says it is framed");
// Every node lands inside the pane, projected exactly as `view` projects.
let rect = egui::Rect::from_center_size(egui::pos2(110.0, 80.0), viewport);
let origin = rect.center() + egui::vec2(pan_x, pan_y);
let model = tab.browser.model().clone();
assert!(!model.nodes.is_empty(), "the demo constellation has nodes to frame");
for n in &model.nodes {
let p = origin + egui::vec2(n.pos.x, n.pos.y) * zoom;
assert!(
rect.contains(p),
"node {} projects to ({:.0}, {:.0}), outside the {} x {} pane after ⊙ Frame",
n.id,
p.x,
p.y,
viewport.x,
viewport.y
);
}
// An EMPTY pane has nothing to frame, and framing nothing must not move the
// camera — the identity-value trap: a fit that always writes a number would pass
// the zoom assertion above and be wrong here.
let mut empty = FalkorTab::empty(TITLE);
empty.frame_graph(viewport);
assert_eq!(empty.camera(), (1.0, 0.0, 0.0), "framing an empty graph moves nothing");
assert_eq!(empty.state_json()["framed"], false);
}
}