Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
    }
}