From 47a8679b5267ccdb5bb794168fa306a787abbae5 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Fri, 2 Oct 2026 09:46:26 -0700 Subject: [PATCH 1/6] Drill-down breadcrumbs + query-box nav (.root/.parent/.schema/.data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Focus trail with cached jumps: breadcrumb bar (Root › A › B), parent/root shortcuts, no DB reload - Dot-commands in query box: .root, .parent (.up/.back), .schema [on|off|toggle], .data/.graph; unknown .foo rejected - Bare / clears search matches + search zoom back to root - Header Data/Schema toggle + clickable schema badge - README: query-box command table, usage steps --- Cargo.lock | 4 +- README.md | 22 ++- src/ui.rs | 462 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 467 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 498f5b6..e634c0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3406,9 +3406,9 @@ dependencies = [ [[package]] name = "lbug" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417db8d481578b1014953a1ce24508382bd791938bb974f5462f1621cd1cfd3d" +checksum = "862a51324bf4785f3f1e4dca9b86d38745b543c0f06b1a5731956e66979879c3" dependencies = [ "arrow 55.2.0", "cmake", diff --git a/README.md b/README.md index 34be7ea..dac5f5d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ This is the native port of [bugscope-tauri](https://github.com/LadybugDB/bugscop - **Visual Encoding** - Node size reflects connection count (more connections = larger nodes), and colors differentiate entity types. - **Dark/Light Mode** - Follows the system appearance automatically, same themes as the Tauri app. - **Relationship Labels** - Hover over edges to see the type of relationship between connected nodes. -- **Search + Focus** - Substring search over node properties; click a match to focus its 1-hop neighborhood. -- **Schema View** - Toggle to see node tables instead of the edge graph. +- **Search + Focus** - Substring search over node properties (`/foo`, bare `/` clears); click a match to focus its 1-hop neighborhood. +- **Breadcrumbs + dot-commands** - Drill-down trail under the header (`Root › A › B`, cached jumps) plus query-box `.root` / `.parent` (`.up`/`.back`) and `.schema` / `.data` (schema view without the menu). +- **Schema View** - Header `Data`/`Schema` toggle (or `.schema` / `.data` in the query box, `File → Toggle Schema View` menu) to switch between node tables and the edge graph. The breadcrumb badge (`schema ✕`) is also a one-click way back to data. - **Live Layout** - Force simulation (repulsion + springs + damping) runs at 30 Hz and settles when the layout goes quiet; pause/resume any time. - **Leiden Treemap** - Header toggle switches the canvas between the graph and a squarified treemap of Leiden communities (in-process icebug Leiden, same family as `GDS_LEIDEN`); tile area follows PageRank weight. Double-click drills into a neighborhood. - **Insights Pane** - Collapsible right pane with the top 10 nodes by PageRank plus the Leiden community summary; clicking a row focuses (PageRank) or selects (community) it. @@ -25,8 +26,23 @@ This is the native port of [bugscope-tauri](https://github.com/LadybugDB/bugscop 4. Scroll to zoom in/out (zooms at the cursor), click and drag the canvas to pan 5. Hover over nodes to see their labels 6. Hover over edges to see relationship types -7. Double-click a node (or select it and choose "Expand neighborhood") to focus its 1-hop neighborhood +7. Double-click a node (or select it and choose "Expand neighborhood") to focus its 1-hop neighborhood. + Each focus pushes the breadcrumb trail under the header (`Root › A › B`): + click any crumb to jump back, or use `↑ Parent` / `⟲ Root` on the right. 8. Type in the query box and press Enter to search; click a match to focus it +9. Switch between the data graph and the schema (table) view with the header `Data`/`Schema` toggle, `.schema` / `.data` in the query box, or `File → Toggle Schema View` + +### Query box: search, Cypher, and dot-commands + +| Input | Effect | +|---|---| +| `/foo` | Substring search over node `name`/`title`/`label`/`id` + properties. Matches list in the sidebar; starting a new search first resets any zoom so matches are in full-graph context. | +| `/` (bare slash) | Clears text-search state: sidebar matches + any search zoom/selection, back to the root view. | +| `.root` | Breadcrumb root: clear the drill-down trail, show the full graph (or current Cypher result) from cache — no DB reload. | +| `.parent` (aliases: `.up`, `.back`) | One step up the trail (`B` → `A` → root). Reports `Already at root` at the top. | +| `.schema` / `.schema on` | Switch to the schema view (node tables + rel connectivity). Already there → resets to the schema root. Same as the header `Schema` pill. | +| `.data` / `.graph` (also `.schema off`) | Leave the schema view, back to the data graph. Same as the header `Data` pill or clicking the `schema ✕` badge in the breadcrumb bar. `.schema toggle` flips either way. | +| anything else | Runs as read Cypher and replaces the graph (new root, trail cleared). Unknown `.foo` is rejected with the valid list — it never runs as Cypher. | ## Prerequisites diff --git a/src/ui.rs b/src/ui.rs index adcedeb..4d716a0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -47,6 +47,15 @@ pub enum ViewMode { Treemap, } +/// One step of the drill-down trail: a focused 1-hop neighborhood. +/// `label` is captured at focus time so breadcrumbs stay readable even +/// after `full` is replaced (Cypher / reload). +#[derive(Clone)] +struct Crumb { + id: String, + label: String, +} + /// One row of the Top PageRank list in the insights pane. #[derive(Clone)] struct TopRank { @@ -105,6 +114,10 @@ pub struct RootView { query_cursor: usize, search_results: Vec, focused: Option, + /// Drill-down trail: focus history, empty = root (`full`). + /// Each entry is a 1-hop `focus_node` step; breadcrumbs + `.root` / + /// `.parent` navigate it without a DB reload. + trail: Vec, hovered: Option, selected: Option, schema_mode: bool, @@ -192,6 +205,7 @@ impl RootView { query_cursor: 0, search_results: Vec::new(), focused: None, + trail: Vec::new(), hovered: None, selected: None, schema_mode: opts.schema_mode, @@ -280,7 +294,13 @@ impl RootView { self.model.settle(); self.frame_initial(None); self.focused = None; + self.trail.clear(); self.selected = None; + self.hovered = None; + self.treemap_hover = None; + self.drag_node = None; + self.panning = None; + self.search_results.clear(); self.running = !self.schema_mode; self.shown = data; self.refresh_analytics(); @@ -290,6 +310,133 @@ impl RootView { } } + /// Display name for a breadcrumb: node name when known, else the id. + fn crumb_label(&self, node_id: &str) -> String { + self.full + .nodes + .iter() + .find(|n| n.id == node_id) + .map(|n| truncate_label(&n.name)) + .or_else(|| { + self.shown + .nodes + .iter() + .find(|n| n.id == node_id) + .map(|n| truncate_label(&n.name)) + }) + .unwrap_or_else(|| truncate_label(node_id)) + } + + /// Show `data` without touching the DB: the shared tail of every + /// trail navigation (`go_root` / `go_parent` / breadcrumb jump). + fn display( + &mut self, + data: GraphData, + focused: Option, + status: String, + cx: &mut Context, + ) { + self.focused = focused.clone(); + self.selected = None; + self.hovered = None; + self.treemap_hover = None; + self.drag_node = None; + self.panning = None; + self.model.load(&data); + self.model.settle(); + self.frame_initial(focused.as_deref()); + self.running = !self.schema_mode && !data.nodes.is_empty(); + // Focus keeps the layout alive so the neighborhood settles + // around the centered node; root reuses the settle above. + if focused.is_some() { + self.running = true; + } + self.shown = data; + self.refresh_analytics(); + self.set_status(status, cx); + } + + fn db_name(&self) -> String { + self.selected_db + .and_then(|id| self.databases.get(id)) + .map(|d| d.name.clone()) + .unwrap_or_else(|| "graph".to_string()) + } + + /// Re-apply the current trail after it was mutated (pop / truncate). + /// Uses the cached `full` graph — no DB round-trip. + fn apply_trail(&mut self, cx: &mut Context) { + let Some(last) = self.trail.last().cloned() else { + let data = self.full.clone(); + let msg = format!( + "{}: {} nodes, {} edges", + self.db_name(), + data.nodes.len(), + data.links.len() + ); + self.display(data, None, msg, cx); + return; + }; + let view = backend::neighborhood(&self.full, &last.id); + if view.nodes.is_empty() { + self.set_status(format!("Node {} not in loaded graph", last.id), cx); + return; + } + self.display( + view, + Some(last.id.clone()), + format!("Neighborhood of {}", last.label), + cx, + ); + } + + /// Breadcrumb / `.root`: back to the full graph (or Cypher result). + pub fn go_root(&mut self, cx: &mut Context) { + if self.trail.is_empty() && self.focused.is_none() { + self.set_status("Already at root", cx); + return; + } + self.trail.clear(); + self.apply_trail(cx); + } + + /// Breadcrumb / `.parent`: one step up the drill-down trail. + pub fn go_parent(&mut self, cx: &mut Context) { + if self.trail.is_empty() { + self.set_status("Already at root", cx); + return; + } + self.trail.pop(); + self.apply_trail(cx); + } + + /// Jump to breadcrumb `index` (`None` = root). + pub fn go_to_crumb(&mut self, index: Option, cx: &mut Context) { + match index { + None => self.go_root(cx), + Some(i) if i + 1 >= self.trail.len() => { + // Already on (or past) this crumb — no-op, don't re-settle. + } + Some(i) => { + self.trail.truncate(i + 1); + self.apply_trail(cx); + } + } + } + + /// `.schema [on|off]`: deterministically enter/leave the schema view. + /// No arg (or `on`) enters it; `off`/`data`/`graph` leaves it. + pub fn set_schema_mode(&mut self, enabled: bool, cx: &mut Context) { + if self.schema_mode == enabled { + // Already there — still reset any drill-down so `.schema` + // always lands on the schema root. + self.go_root(cx); + return; + } + self.schema_mode = enabled; + self.load_graph(cx); + } + pub fn open_file_dialog(&mut self, cx: &mut Context) { let receiver = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -567,18 +714,24 @@ impl RootView { } } - /// Enter in the query bar: `/foo` searches node names/properties, - /// anything else runs as a Cypher query and replaces the graph. + /// Enter in the query bar: dot-commands (`.root` / `.parent` / + /// `.schema`), `/foo` node search, or Cypher (replaces the graph). + /// Bare `/` clears text-search state (matches + search zoom). fn run_query(&mut self, cx: &mut Context) { - let Some(id) = self.selected_db else { return }; - let Some(db) = self.databases.get(id) else { - return; - }; let raw = self.query.clone(); let raw = raw.trim().to_string(); if raw.is_empty() { return; } + // Dot-commands first — they never need a database handle. + if raw.starts_with('.') { + self.run_dot_command(&raw, cx); + return; + } + let Some(id) = self.selected_db else { return }; + let Some(db) = self.databases.get(id) else { + return; + }; let is_search = raw.starts_with('/'); let mut term = raw.trim_start_matches('/').trim().to_string(); // Accept both `/rdf` and `/search rdf` (the old hint text @@ -588,6 +741,14 @@ impl RootView { term = rest.trim().to_string(); } } + if is_search && term.is_empty() { + // Bare `/`: clear matches + any search zoom, back to root. + self.clear_text_search(cx); + self.query.clear(); + self.query_cursor = 0; + cx.notify(); + return; + } if term.is_empty() { return; } @@ -606,6 +767,24 @@ impl RootView { match work { Ok(data) => { if is_search { + // A new text search starts from the root so matches + // are in full-graph context: drop any previous + // search-driven zoom/selection first. + if self.focused.is_some() || !self.trail.is_empty() { + self.trail.clear(); + let root = self.full.clone(); + let msg = format!( + "{}: {} nodes, {} edges", + self.db_name(), + root.nodes.len(), + root.links.len() + ); + self.display(root, None, msg, cx); + } else { + self.selected = None; + self.hovered = None; + self.treemap_hover = None; + } let n = data.nodes.len(); self.search_results = data.nodes; if n > 0 { @@ -616,6 +795,7 @@ impl RootView { self.set_status(format!("{n} matches for {term} — click to focus"), cx); } else { self.search_results.clear(); + self.trail.clear(); self.focused = None; self.selected = None; self.full = data.clone(); @@ -633,20 +813,122 @@ impl RootView { } } + /// `.root` / `.parent` / `.schema` / `.data` — query-box navigation commands. + /// Unknown `.foo` reports the valid set instead of running Cypher. + fn run_dot_command(&mut self, raw: &str, cx: &mut Context) { + let mut parts = raw.split_whitespace(); + let cmd = parts.next().unwrap_or("").to_ascii_lowercase(); + let arg = parts.next().unwrap_or("").to_ascii_lowercase(); + let clear_input = |view: &mut Self, cx: &mut Context| { + view.query.clear(); + view.query_cursor = 0; + cx.notify(); + }; + match cmd.as_str() { + ".root" => { + self.go_root(cx); + clear_input(self, cx); + } + ".parent" | ".up" | ".back" => { + self.go_parent(cx); + clear_input(self, cx); + } + ".schema" => { + let enabled = match arg.as_str() { + "" | "on" | "schema" => true, + "off" | "data" | "graph" | "no" => false, + "toggle" => !self.schema_mode, + _ => { + self.set_status( + format!("Unknown .schema arg {arg:?} — try .schema, .schema on/off"), + cx, + ); + return; + } + }; + self.set_schema_mode(enabled, cx); + clear_input(self, cx); + } + // First-class way back from `.schema`: `.data` == `.schema off`. + ".data" | ".graph" => { + if !arg.is_empty() { + self.set_status(format!("Unknown .data arg {arg:?} — try .data"), cx); + return; + } + self.set_schema_mode(false, cx); + clear_input(self, cx); + } + _ => { + self.set_status( + "Unknown command — try .root, .parent, .data, .schema".to_string(), + cx, + ); + } + } + } + + /// Bare-`/` behavior: drop text-search matches + any zoom/selection + /// so the next `/foo` starts from a clean root view. + fn clear_text_search(&mut self, cx: &mut Context) { + self.search_results.clear(); + if self.trail.is_empty() && self.focused.is_none() { + self.selected = None; + self.hovered = None; + self.treemap_hover = None; + self.set_status("Search cleared", cx); + return; + } + self.trail.clear(); + let root = self.full.clone(); + let msg = format!( + "{}: {} nodes, {} edges — search cleared", + self.db_name(), + root.nodes.len(), + root.links.len() + ); + self.display(root, None, msg, cx); + } + fn focus_node(&mut self, node_id: &str, cx: &mut Context) { let view = backend::neighborhood(&self.full, node_id); if view.nodes.is_empty() { self.set_status(format!("Node {node_id} not in loaded graph"), cx); return; } - self.focused = Some(node_id.to_string()); - self.model.load(&view); - self.model.settle(); - self.frame_initial(Some(node_id)); - self.running = true; - self.shown = view; - self.refresh_analytics(); - self.set_status(format!("Neighborhood of {node_id}"), cx); + // Push the drill-down step (skip duplicates from double-clicks). + // The label is captured now so breadcrumbs survive later `full` + // replacements (Cypher / reload). + if self + .trail + .last() + .map(|c| c.id.as_str() != node_id) + .unwrap_or(true) + { + let label = self.crumb_label(node_id); + self.trail.push(Crumb { + id: node_id.to_string(), + label: label.clone(), + }); + self.display( + view, + Some(node_id.to_string()), + format!("Neighborhood of {label}"), + cx, + ); + } else { + self.display( + view, + Some(node_id.to_string()), + format!( + "Neighborhood of {}", + self.trail + .last() + .map(|c| c.label.clone()) + .unwrap_or_else(|| node_id.to_string()) + ), + cx, + ); + } } /// Default framing: zoomed in on the densest viewport with labels on. @@ -758,6 +1040,35 @@ impl RootView { // The theme button flips between the two palettes; the glyph shows // what you get when you click (sun on dark, moon on light). let theme_button = if theme.dark { "☀" } else { "☾" }; + // Data ↔ schema toggle: same control language as the view toggle. + // Active side reads as a filled pill; the other as a quiet button. + let mut schema_toggle = div().flex().flex_row().gap_1(); + for (label, enabled) in [("Data", false), ("Schema", true)] { + let active = self.schema_mode == enabled; + schema_toggle = schema_toggle.child( + div() + .px_2() + .py_1() + .rounded_md() + .text_sm() + .bg(if active { + theme.accent + } else { + theme.selection + }) + .text_color(if active { + theme.on_accent + } else { + theme.foreground + }) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |view, _, _, cx| view.set_schema_mode(enabled, cx)), + ) + .child(label.to_string()), + ); + } // View toggle: graph ↔ Leiden treemap. The active view reads as a // filled pill; the other as a quiet button. let mut toggle = div().flex().flex_row().gap_1(); @@ -806,6 +1117,7 @@ impl RootView { .justify_end() .items_center() .gap_2() + .child(schema_toggle) .child(toggle) .child( div() @@ -829,6 +1141,121 @@ impl RootView { ) } + /// Breadcrumb bar under the header: `Root › A › B` for the drill-down + /// trail. Clicking a crumb jumps there (cached, no DB reload); the + /// trailing `↑` / `⟲` shortcuts mirror `.parent` / `.root`. + fn render_breadcrumbs(&mut self, theme: Theme, cx: &mut Context) -> Div { + let trail = self.trail.clone(); + let mut row = div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .gap_1() + .px_3() + .py_1() + .bg(theme.surface) + .text_color(theme.foreground) + .text_xs(); + // Root crumb: active pill at root, clickable shortcut above it. + let at_root = trail.is_empty(); + let root_el = div() + .px_2() + .py(px(2.)) + .rounded_md() + .bg(if at_root { + theme.accent + } else { + theme.selection + }) + .text_color(if at_root { + theme.on_accent + } else { + theme.foreground + }) + .cursor_pointer() + .hover(|s| s.bg(if at_root { theme.accent } else { theme.border })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, _, _, cx| view.go_root(cx)), + ) + .child("Root".to_string()); + row = row.child(root_el); + for (i, crumb) in trail.iter().enumerate() { + let last = i + 1 == trail.len(); + row = row.child(div().text_color(theme.secondary).child("›".to_string())); + let label = crumb.label.clone(); + let el = div() + .px_2() + .py(px(2.)) + .rounded_md() + .bg(if last { theme.accent } else { theme.selection }) + .text_color(if last { + theme.on_accent + } else { + theme.foreground + }) + .cursor_pointer() + .hover(|s| s.bg(if last { theme.accent } else { theme.border })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |view, _, _, cx| view.go_to_crumb(Some(i), cx)), + ) + .child(label); + row = row.child(el); + } + if self.schema_mode { + // Clickable way back: the badge leaves the schema view. + row = row.child( + div() + .px_2() + .py(px(2.)) + .rounded_md() + .bg(theme.accent) + .text_color(theme.on_accent) + .cursor_pointer() + .hover(|s| s.bg(theme.border)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, _, _, cx| view.set_schema_mode(false, cx)), + ) + .child("schema ✕".to_string()), + ); + } + if !at_root { + row = row.child(div().flex_1()); + row = row.child( + div() + .px_2() + .py(px(2.)) + .rounded_md() + .bg(theme.selection) + .cursor_pointer() + .hover(|s| s.bg(theme.border)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, _, _, cx| view.go_parent(cx)), + ) + .child("↑ Parent".to_string()), + ); + row = row.child( + div() + .px_2() + .py(px(2.)) + .rounded_md() + .bg(theme.selection) + .cursor_pointer() + .hover(|s| s.bg(theme.border)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, _, _, cx| view.go_root(cx)), + ) + .child("⟲ Root".to_string()), + ); + } + row + } + /// Divider gutter between the sidebar and the canvas: a 1px line split /// into top/bottom segments with the collapse chevron straddling its /// vertical middle. Always rendered so the bar can be reopened when @@ -1318,7 +1745,8 @@ impl Render for RootView { } } else if self.query.is_empty() && !qfocused { ( - "/rdf searches nodes · anything else is Cypher — Enter".to_string(), + "/foo search · / clears · .root .parent .schema .data · else Cypher — Enter" + .to_string(), String::new(), String::new(), ) @@ -1362,6 +1790,7 @@ impl Render for RootView { .bg(theme.background) .text_color(theme.foreground) .child(self.render_header(theme, cx)) + .child(self.render_breadcrumbs(theme, cx)) .child( div() .flex() @@ -1374,7 +1803,8 @@ impl Render for RootView { .child(self.render_right_divider(theme, cx)) .children(insights), ) - // Compact query bar at the bottom: `/foo` searches, anything + // Compact query bar at the bottom: `/foo` searches, bare `/` + // clears, `.root` / `.parent` / `.schema` / `.data` navigate, anything // else runs as Cypher. Enter submits; Escape clears. .child( div() From 706bc292d591340d12ff7b4aec42ce7e83b2889d Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Fri, 2 Oct 2026 09:50:35 -0700 Subject: [PATCH 2/6] Default to treemap view above 64 edges - TREEMAP_AUTO_EDGES + auto_view_mode applied on every graph change (load, Cypher, drill-down); header toggle still overrides for the current view - tests/auto_view.rs boundary test, README note --- README.md | 2 +- src/ui.rs | 19 +++++++++++++++++++ tests/auto_view.rs | 8 ++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/auto_view.rs diff --git a/README.md b/README.md index dac5f5d..db737da 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This is the native port of [bugscope-tauri](https://github.com/LadybugDB/bugscop - **Breadcrumbs + dot-commands** - Drill-down trail under the header (`Root › A › B`, cached jumps) plus query-box `.root` / `.parent` (`.up`/`.back`) and `.schema` / `.data` (schema view without the menu). - **Schema View** - Header `Data`/`Schema` toggle (or `.schema` / `.data` in the query box, `File → Toggle Schema View` menu) to switch between node tables and the edge graph. The breadcrumb badge (`schema ✕`) is also a one-click way back to data. - **Live Layout** - Force simulation (repulsion + springs + damping) runs at 30 Hz and settles when the layout goes quiet; pause/resume any time. -- **Leiden Treemap** - Header toggle switches the canvas between the graph and a squarified treemap of Leiden communities (in-process icebug Leiden, same family as `GDS_LEIDEN`); tile area follows PageRank weight. Double-click drills into a neighborhood. +- **Leiden Treemap** - Header toggle switches the canvas between the graph and a squarified treemap of Leiden communities (in-process icebug Leiden, same family as `GDS_LEIDEN`); tile area follows PageRank weight. Double-click drills into a neighborhood. Graphs with more than 64 edges open in the treemap by default (smaller ones in the graph view); the toggle still overrides for the current view. - **Insights Pane** - Collapsible right pane with the top 10 nodes by PageRank plus the Leiden community summary; clicking a row focuses (PageRank) or selects (community) it. ## Usage diff --git a/src/ui.rs b/src/ui.rs index 4d716a0..cfa8bd2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -47,6 +47,22 @@ pub enum ViewMode { Treemap, } +/// Auto-switch threshold: a displayed graph with more than this many edges +/// opens in the treemap view by default (the Leiden overview scales better +/// than the force graph); smaller ones open in the graph view. Applies on +/// every graph change (load, Cypher, drill-down); the header toggle still +/// overrides for the current view. +pub const TREEMAP_AUTO_EDGES: usize = 64; + +/// Default view for an edge count: treemap above the threshold. +pub fn auto_view_mode(edge_count: usize) -> ViewMode { + if edge_count > TREEMAP_AUTO_EDGES { + ViewMode::Treemap + } else { + ViewMode::Graph + } +} + /// One step of the drill-down trail: a focused 1-hop neighborhood. /// `label` is captured at focus time so breadcrumbs stay readable even /// after `full` is replaced (Cypher / reload). @@ -302,6 +318,7 @@ impl RootView { self.panning = None; self.search_results.clear(); self.running = !self.schema_mode; + self.view_mode = auto_view_mode(data.links.len()); self.shown = data; self.refresh_analytics(); self.set_status(msg, cx); @@ -351,6 +368,7 @@ impl RootView { if focused.is_some() { self.running = true; } + self.view_mode = auto_view_mode(data.links.len()); self.shown = data; self.refresh_analytics(); self.set_status(status, cx); @@ -804,6 +822,7 @@ impl RootView { self.frame_initial(None); self.running = !data.nodes.is_empty() && !self.schema_mode; let (nn, ne) = (data.nodes.len(), data.links.len()); + self.view_mode = auto_view_mode(ne); self.shown = data; self.refresh_analytics(); self.set_status(format!("Cypher: {nn} nodes, {ne} edges"), cx); diff --git a/tests/auto_view.rs b/tests/auto_view.rs new file mode 100644 index 0000000..47689e2 --- /dev/null +++ b/tests/auto_view.rs @@ -0,0 +1,8 @@ +use bugscope::ui::{auto_view_mode, ViewMode, TREEMAP_AUTO_EDGES}; + +#[test] +fn treemap_is_default_above_threshold() { + assert_eq!(auto_view_mode(0), ViewMode::Graph); + assert_eq!(auto_view_mode(TREEMAP_AUTO_EDGES), ViewMode::Graph); + assert_eq!(auto_view_mode(TREEMAP_AUTO_EDGES + 1), ViewMode::Treemap); +} From 9b6523d3eea186254660415032bf04f3a0b95b00 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Fri, 2 Oct 2026 10:09:55 -0700 Subject: [PATCH 3/6] Sunburst view + sticky view choice - Sunburst (Leiden) view next to treemap: root disc at center, community ring, PageRank-sized member ring with parent-hue tints; center click steps up the trail; header toggle, View menu, cmd-3; polar layout/pick unit-tested in clusters.rs - View choice (graph/treemap/sunburst) now made once at startup from the 64-edge threshold and sticks; navigation (drill-down, breadcrumbs, Cypher, reload) never switches views, and the insights community row selects without jumping views --- README.md | 4 +- src/clusters.rs | 278 ++++++++++++++++++++++++++++ src/main.rs | 6 + src/ui.rs | 474 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 744 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index db737da..6cd9b66 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This is the native port of [bugscope-tauri](https://github.com/LadybugDB/bugscop - **Breadcrumbs + dot-commands** - Drill-down trail under the header (`Root › A › B`, cached jumps) plus query-box `.root` / `.parent` (`.up`/`.back`) and `.schema` / `.data` (schema view without the menu). - **Schema View** - Header `Data`/`Schema` toggle (or `.schema` / `.data` in the query box, `File → Toggle Schema View` menu) to switch between node tables and the edge graph. The breadcrumb badge (`schema ✕`) is also a one-click way back to data. - **Live Layout** - Force simulation (repulsion + springs + damping) runs at 30 Hz and settles when the layout goes quiet; pause/resume any time. -- **Leiden Treemap** - Header toggle switches the canvas between the graph and a squarified treemap of Leiden communities (in-process icebug Leiden, same family as `GDS_LEIDEN`); tile area follows PageRank weight. Double-click drills into a neighborhood. Graphs with more than 64 edges open in the treemap by default (smaller ones in the graph view); the toggle still overrides for the current view. +- **Leiden Treemap + Sunburst** - Header toggle (or `⌘1`/`⌘2`/`⌘3`) switches the canvas between the graph, a squarified treemap of Leiden communities, and a classic sunburst of the same communities (in-process icebug Leiden, same family as `GDS_LEIDEN`): the center disc is the root of the displayed graph, rings move outward with hierarchy depth (communities, then members), and every slice is proportional to PageRank weight with members tinted from their community hue. Double-click drills into a neighborhood; clicking the sunburst center steps one level up the trail. At startup, graphs with more than 64 edges open in the treemap (smaller ones in the graph view); the choice then sticks — navigating never switches views, only the header toggle, menu, or `⌘1`/`⌘2`/`⌘3` do. - **Insights Pane** - Collapsible right pane with the top 10 nodes by PageRank plus the Leiden community summary; clicking a row focuses (PageRank) or selects (community) it. ## Usage @@ -117,7 +117,7 @@ The backend runs the same Cypher as the Tauri commands (`MATCH (a)-[r]->(b) RETU ## Deliberately out of scope for v1 -Summary-space PageRank sidecars, LLM cluster naming, Voronoi overlay, the lever panel, Arrow IPC transport — all were web-renderer or sidecar concerns in bugscope-tauri. The native port loads the edge graph directly and lays it out live. The sunburst view is skipped for now (arc-heavy painting in GPUI needs more scaffolding than the treemap's rects). +Summary-space PageRank sidecars, LLM cluster naming, Voronoi overlay, the lever panel, Arrow IPC transport — all were web-renderer or sidecar concerns in bugscope-tauri. The native port loads the edge graph directly and lays it out live. ### Troubleshooting diff --git a/src/clusters.rs b/src/clusters.rs index c8b7850..9ab7e66 100644 --- a/src/clusters.rs +++ b/src/clusters.rs @@ -314,6 +314,175 @@ pub fn hit(tiles: &[Tile], x: f32, y: f32) -> Option<&Tile> { tiles.iter().rev().find(|tile| tile.rect.contains(x, y)) } +/// Sunburst (radial partition) over Leiden communities: pure geometry. +/// +/// The inner ring holds one arc per community, sized by community weight; +/// the outer ring subdivides each community's span into member arcs sized +/// by member weight. Angles start at the top (−π/2) and run clockwise in +/// screen space (y down). Painting + canvas wiring live in `ui.rs`. +#[derive(Clone, Debug)] +pub struct SunburstWedge { + /// Position of the community in the layout's community list. + pub community: usize, + /// `Some(node_idx)` for a member arc, `None` for a community arc. + pub node: Option, + /// Angular span in radians, measured clockwise from the top. + pub start: f32, + pub end: f32, + /// Absolute radii in canvas pixels. + pub inner: f32, + pub outer: f32, +} + +impl SunburstWedge { + pub fn span(&self) -> f32 { + (self.end - self.start).max(0.0) + } + + pub fn mid_angle(&self) -> f32 { + (self.start + self.end) / 2.0 + } + + pub fn mid_radius(&self) -> f32 { + (self.inner + self.outer) / 2.0 + } + + /// Arc length at the mid radius — the readability/hit metric, + /// mirroring `min_tile` in the treemap layout. + pub fn arc_len(&self) -> f32 { + self.span() * self.mid_radius() + } +} + +/// Center + radius for the sunburst in a viewport, shared by paint + +/// hit-testing so both always agree. +pub fn sunburst_frame(vw: f32, vh: f32) -> (f32, f32, f32) { + let w = vw.max(50.0); + let h = vh.max(50.0); + (w / 2.0, h / 2.0, (w.min(h) / 2.0 - 8.0).max(20.0)) +} + +/// Fraction of the radius kept as the center circle (the "up" affordance). +pub const SUNBURST_CENTER_FRAC: f32 = 0.30; + +/// Lay out `communities` as sunburst wedges for a `radius`-px sunburst. +/// Member arcs narrower than ~2px at their mid radius are dropped — they +/// can neither be read nor hit, like sub-`min_tile` treemap cells. +pub fn layout_sunburst(communities: &[Community], radius: f32) -> Vec { + let mut wedges = Vec::new(); + if communities.is_empty() || radius <= 0.0 { + return wedges; + } + let total: f64 = communities.iter().map(|c| c.weight.max(0.0)).sum(); + let n = communities.len(); + let two_pi = std::f32::consts::TAU; + let base = -std::f32::consts::FRAC_PI_2; + let (c_inner, c_outer) = (radius * SUNBURST_CENTER_FRAC, radius * 0.62); + let (m_inner, m_outer) = (radius * 0.64, radius * 0.97); + let mut a = base; + for (pos, comm) in communities.iter().enumerate() { + let share = if total > 0.0 { + (comm.weight.max(0.0) / total) as f32 + } else { + 1.0 / n.max(1) as f32 + }; + let (a0, a1) = (a, a + share * two_pi); + a = a1; + if a1 <= a0 { + continue; + } + wedges.push(SunburstWedge { + community: pos, + node: None, + start: a0, + end: a1, + inner: c_inner, + outer: c_outer, + }); + let mtotal: f64 = comm.weights.iter().map(|w| (*w).max(0.0)).sum(); + let mcount = comm.members.len().max(1) as f64; + let mut ma = a0; + for (k, &m) in comm.members.iter().enumerate() { + let w = comm.weights.get(k).copied().unwrap_or(1.0).max(0.0); + let mspan = if mtotal > 0.0 { + (w / mtotal) as f32 * (a1 - a0) + } else { + (a1 - a0) / mcount as f32 + }; + let (m0, m1) = (ma, ma + mspan); + ma = m1; + if m1 <= m0 { + continue; + } + let wedge = SunburstWedge { + community: pos, + node: Some(m), + start: m0, + end: m1, + inner: m_inner, + outer: m_outer, + }; + if wedge.arc_len() < 2.0 { + continue; + } + wedges.push(wedge); + } + } + wedges +} + +/// Polar pick result for a canvas-local point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SunburstHit { + /// Inside the center circle — the "up" affordance. + Center, + /// A wedge: community position + optional member node index. + Wedge { + community: usize, + node: Option, + }, +} + +/// Pick the sunburst at a canvas-local point. `radius` is the outer radius +/// from [`sunburst_frame`]; points outside it miss (`None`). +pub fn pick_sunburst( + wedges: &[SunburstWedge], + cx: f32, + cy: f32, + radius: f32, + x: f32, + y: f32, +) -> Option { + let dx = x - cx; + let dy = y - cy; + let r = dx.hypot(dy); + if r > radius { + return None; + } + if r < radius * SUNBURST_CENTER_FRAC { + return Some(SunburstHit::Center); + } + let base = -std::f32::consts::FRAC_PI_2; + let mut a = dy.atan2(dx) - base; + while a < 0.0 { + a += std::f32::consts::TAU; + } + while a >= std::f32::consts::TAU { + a -= std::f32::consts::TAU; + } + let a = a + base; + // Outer (member) wedges are emitted after their community arc, so + // reverse order prefers the most specific ring — like `hit` above. + wedges + .iter() + .rev() + .find(|w| a >= w.start && a < w.end && r >= w.inner && r <= w.outer) + .map(|w| SunburstHit::Wedge { + community: w.community, + node: w.node, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -409,6 +578,115 @@ mod tests { assert!(hit(&tiles, -50.0, -50.0).is_none()); } + #[test] + fn sunburst_spans_partition_the_circle() { + let cs = two_cliques(); + let wedges = layout_sunburst(&cs, 250.0); + let comm_spans: f32 = wedges + .iter() + .filter(|w| w.node.is_none()) + .map(|w| w.span()) + .sum(); + assert!( + (comm_spans - std::f32::consts::TAU).abs() < 1e-3, + "community spans cover the circle: {comm_spans}" + ); + // 2 community arcs + 8 member arcs, all members kept at this size. + assert_eq!(wedges.iter().filter(|w| w.node.is_none()).count(), 2); + assert_eq!(wedges.iter().filter(|w| w.node.is_some()).count(), 8); + // Every member span sits inside its parent community span. + for m in wedges.iter().filter(|w| w.node.is_some()) { + let parent = wedges + .iter() + .find(|w| w.node.is_none() && w.community == m.community) + .expect("parent arc"); + assert!(m.start >= parent.start - 1e-4 && m.end <= parent.end + 1e-4); + } + } + + #[test] + fn sunburst_pick_resolves_rings() { + let cs = two_cliques(); + let radius = 250.0; + let wedges = layout_sunburst(&cs, radius); + let (cx, cy, _) = (400.0, 250.0, radius); + // Center circle → up affordance; outside → miss. + assert_eq!( + pick_sunburst(&wedges, cx, cy, radius, cx, cy), + Some(SunburstHit::Center) + ); + assert_eq!( + pick_sunburst(&wedges, cx, cy, radius, cx + radius + 50.0, cy), + None + ); + // A point safely inside the first community arc → that arc. (The + // exact midpoint can sit on a shared span boundary in f32.) + let first = wedges.iter().find(|w| w.node.is_none()).unwrap(); + let probe_a = first.start + first.span() * 0.25; + let mid_r = first.mid_radius(); + let x = cx + mid_r * probe_a.cos(); + let y = cy + mid_r * probe_a.sin(); + assert_eq!( + pick_sunburst(&wedges, cx, cy, radius, x, y), + Some(SunburstHit::Wedge { + community: first.community, + node: None, + }) + ); + // A member arc midpoint resolves to the member. + let member = wedges.iter().find(|w| w.node.is_some()).unwrap(); + let (ma, mr) = (member.mid_angle(), member.mid_radius()); + let x = cx + mr * ma.cos(); + let y = cy + mr * ma.sin(); + assert_eq!( + pick_sunburst(&wedges, cx, cy, radius, x, y), + Some(SunburstHit::Wedge { + community: member.community, + node: member.node, + }) + ); + } + + #[test] + fn sunburst_rings_nest_root_community_member() { + let cs = two_cliques(); + let radius = 250.0; + let wedges = layout_sunburst(&cs, radius); + // Community ring starts where the root disc ends; members sit + // outside their parent ring — root → community → member. + for w in wedges.iter().filter(|w| w.node.is_none()) { + assert!((w.inner - radius * SUNBURST_CENTER_FRAC).abs() < 1e-3); + } + for m in wedges.iter().filter(|w| w.node.is_some()) { + let parent = wedges + .iter() + .find(|w| w.node.is_none() && w.community == m.community) + .expect("parent arc"); + assert!(m.inner >= parent.outer - 1e-4); + assert!(m.outer <= radius + 1e-3); + } + } + + #[test] + fn sunburst_drops_unreadable_members() { + // One heavy node + 200 dust nodes: dust arcs are sub-pixel. + let mut assignment = vec![0u64; 201]; + let mut weights = vec![0.0001f64; 201]; + assignment[0] = 0; + weights[0] = 1000.0; + let cs = build_communities(201, &assignment, Some(&weights)); + assert_eq!(cs.len(), 1); + let wedges = layout_sunburst(&cs, 250.0); + let members = wedges.iter().filter(|w| w.node.is_some()).count(); + assert!(members < 201, "dust filtered: {members} kept"); + assert!( + wedges.iter().any(|w| w.node == Some(0)), + "heavy member kept" + ); + assert!(layout_sunburst(&[], 250.0).is_empty()); + assert!(layout_sunburst(&cs, 0.0).is_empty()); + } + #[test] fn pagerank_weights_scale_community_share() { let weights = [10.0, 10.0, 10.0, 10.0, 1.0, 1.0, 1.0, 1.0]; diff --git a/src/main.rs b/src/main.rs index f982bc2..2e2803e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ actions!( ToggleInsights, ViewGraph, ViewTreemap, + ViewSunburst, ResetView, OpenPreferences, Quit @@ -59,6 +60,9 @@ fn main() { cx.on_action(|_: &ViewTreemap, cx| { with_view(cx, |v, cx| v.set_view_mode(ViewMode::Treemap, cx)) }); + cx.on_action(|_: &ViewSunburst, cx| { + with_view(cx, |v, cx| v.set_view_mode(ViewMode::Sunburst, cx)) + }); cx.on_action(|_: &ResetView, cx| with_view(cx, |v, cx| v.reset_view(cx))); cx.on_action(|_: &OpenPreferences, cx| with_view(cx, |v, cx| v.toggle_preferences(cx))); cx.on_action(|_: &Quit, cx| cx.quit()); @@ -69,6 +73,7 @@ fn main() { KeyBinding::new("cmd-i", ToggleInsights, None), KeyBinding::new("cmd-1", ViewGraph, None), KeyBinding::new("cmd-2", ViewTreemap, None), + KeyBinding::new("cmd-3", ViewSunburst, None), KeyBinding::new("cmd-,", OpenPreferences, None), ]); cx.set_menus(vec![ @@ -99,6 +104,7 @@ fn main() { MenuItem::separator(), MenuItem::action("Graph View", ViewGraph), MenuItem::action("Treemap (Leiden) View", ViewTreemap), + MenuItem::action("Sunburst (Leiden) View", ViewSunburst), MenuItem::separator(), MenuItem::action("Reset View", ResetView), ], diff --git a/src/ui.rs b/src/ui.rs index cfa8bd2..5daf9b4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -39,19 +39,21 @@ struct SnapNode { name: String, } -/// Center-pane view: force-directed graph or Leiden treemap. +/// Center-pane view: force-directed graph, Leiden treemap, or Leiden +/// sunburst (same communities, radial instead of rectangular). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ViewMode { #[default] Graph, Treemap, + Sunburst, } -/// Auto-switch threshold: a displayed graph with more than this many edges -/// opens in the treemap view by default (the Leiden overview scales better -/// than the force graph); smaller ones open in the graph view. Applies on -/// every graph change (load, Cypher, drill-down); the header toggle still -/// overrides for the current view. +/// Startup default threshold: the first graph shown with more than this +/// many edges opens in the treemap view (the Leiden overview scales better +/// than the force graph); smaller ones open in the graph view. Applied +/// only once — afterwards the view sticks and changes only via the header +/// toggle, menu, or shortcuts. pub const TREEMAP_AUTO_EDGES: usize = 64; /// Default view for an edge count: treemap above the threshold. @@ -83,7 +85,8 @@ struct TopRank { /// Everything the treemap needs for one frame: the Leiden communities /// (largest first, shared with hit-testing via the same `Rc`), truncated -/// names per displayed node, and the hover/selection to outline. +/// names per displayed node, and the hover/selection to outline. The +/// sunburst reuses the same snapshot plus `center_label` for its root disc. #[derive(Clone)] struct TreeSnapshot { tree: Rc>, @@ -95,6 +98,9 @@ struct TreeSnapshot { label_font: String, label_font_size: f32, summary: String, + /// Root of the displayed graph for the sunburst center disc: the + /// focused node when drilled in, else the node count. + center_label: String, } pub struct RootView { @@ -104,8 +110,11 @@ pub struct RootView { full: GraphData, model: GraphModel, camera: Camera, - /// Center-pane view, switched by the header toggle. + /// Center-pane view: chosen once at startup by edge count, then sticky + /// (header toggle / menu / shortcuts only). view_mode: ViewMode, + /// Whether the one-shot startup default has been applied. + auto_view_done: bool, /// Currently displayed graph, in model order: full scan, Cypher result, /// or 1-hop neighborhood. Analytics (Leiden/PageRank) run over this. shown: GraphData, @@ -206,6 +215,7 @@ impl RootView { model: GraphModel::default(), camera: Camera::default(), view_mode: ViewMode::Graph, + auto_view_done: false, shown: GraphData::default(), communities: Vec::new(), cluster_modularity: 0.0, @@ -318,7 +328,7 @@ impl RootView { self.panning = None; self.search_results.clear(); self.running = !self.schema_mode; - self.view_mode = auto_view_mode(data.links.len()); + self.maybe_auto_view(data.links.len()); self.shown = data; self.refresh_analytics(); self.set_status(msg, cx); @@ -368,7 +378,7 @@ impl RootView { if focused.is_some() { self.running = true; } - self.view_mode = auto_view_mode(data.links.len()); + self.maybe_auto_view(data.links.len()); self.shown = data; self.refresh_analytics(); self.set_status(status, cx); @@ -512,6 +522,15 @@ impl RootView { cx.notify(); } + /// One-shot startup default: pick the view from the edge count the + /// first time a graph is shown; later graphs keep the current view. + fn maybe_auto_view(&mut self, edge_count: usize) { + if !self.auto_view_done { + self.view_mode = auto_view_mode(edge_count); + self.auto_view_done = true; + } + } + pub fn toggle_right_pane(&mut self, cx: &mut Context) { self.right_open = !self.right_open; cx.notify(); @@ -607,6 +626,35 @@ impl RootView { .find(|t| t.rect.contains(lx, ly)) } + /// Sunburst wedges for the current canvas, shared by paint + + /// hit-testing so both always agree. + fn sunburst_layout(&self) -> (Vec, f32, f32, f32) { + let (vw, vh) = self.canvas_size(); + let (cx, cy, radius) = clusters::sunburst_frame(vw, vh); + let wedges = clusters::layout_sunburst(&self.tree, radius); + (wedges, cx, cy, radius) + } + + /// Sunburst hit under a canvas-local point: center circle, wedge, or + /// miss (outside the disc). + fn pick_sunburst(&self, lx: f32, ly: f32) -> Option { + let (wedges, cx, cy, radius) = self.sunburst_layout(); + clusters::pick_sunburst(&wedges, cx, cy, radius, lx, ly) + } + + /// Displayed-node index for a sunburst hit: the member arc itself, or + /// the heaviest member when the community arc (or center) was hit. + fn sunburst_hit_node(&self, hit: clusters::SunburstHit) -> Option { + match hit { + clusters::SunburstHit::Center => None, + clusters::SunburstHit::Wedge { community, node } => node.or_else(|| { + self.tree + .get(community) + .and_then(|c| c.members.first().copied()) + }), + } + } + /// Displayed-node index shown for a tile: the member cell itself, or /// the heaviest member when the community header was hit. fn treemap_tile_node(&self, tile: &clusters::Tile) -> Option { @@ -621,6 +669,11 @@ impl RootView { let selected_community = self .selected .and_then(|i| self.tree.iter().position(|c| c.members.contains(&i))); + let center_label = self + .trail + .last() + .map(|c| c.label.clone()) + .unwrap_or_else(|| format!("{} nodes", self.shown.nodes.len())); TreeSnapshot { tree: self.tree.clone(), names: self.tree_names.clone(), @@ -631,6 +684,7 @@ impl RootView { label_font: self.label_font.clone(), label_font_size: self.label_font_size, summary: self.cluster_status.clone(), + center_label, } } @@ -822,7 +876,7 @@ impl RootView { self.frame_initial(None); self.running = !data.nodes.is_empty() && !self.schema_mode; let (nn, ne) = (data.nodes.len(), data.links.len()); - self.view_mode = auto_view_mode(ne); + self.maybe_auto_view(ne); self.shown = data; self.refresh_analytics(); self.set_status(format!("Cypher: {nn} nodes, {ne} edges"), cx); @@ -1088,10 +1142,14 @@ impl RootView { .child(label.to_string()), ); } - // View toggle: graph ↔ Leiden treemap. The active view reads as a - // filled pill; the other as a quiet button. + // View toggle: graph ↔ Leiden treemap ↔ Leiden sunburst. The active + // view reads as a filled pill; the others as quiet buttons. let mut toggle = div().flex().flex_row().gap_1(); - for (label, mode) in [("Graph", ViewMode::Graph), ("Treemap", ViewMode::Treemap)] { + for (label, mode) in [ + ("Graph", ViewMode::Graph), + ("Treemap", ViewMode::Treemap), + ("Sunburst", ViewMode::Sunburst), + ] { let active = self.view_mode == mode; toggle = toggle.child( div() @@ -1563,7 +1621,7 @@ impl RootView { /// Collapsible right pane: top 10 nodes by PageRank over the displayed /// graph, plus the Leiden community summary. Clicking a row focuses the /// node's 1-hop neighborhood; clicking a community selects its heaviest - /// member and jumps to the treemap. + /// member (the view sticks — switch it via the header toggle). fn render_insights(&mut self, theme: Theme, cx: &mut Context) -> impl IntoElement { let mut col = div() .flex() @@ -1656,7 +1714,7 @@ impl RootView { cx.listener(move |view, _, _, cx| { if let Some(m) = first { view.selected = Some(m); - view.set_view_mode(ViewMode::Treemap, cx); + cx.notify(); } }), ) @@ -1731,6 +1789,75 @@ impl RootView { } })) } + + fn render_sunburst_canvas(&mut self, theme: Theme, cx: &mut Context) -> Div { + let snap = self.tree_snapshot(theme); + let origin_cell = self.canvas_origin.clone(); + let size_cell = self.canvas_size.clone(); + div() + .flex_1() + .h_full() + .bg(theme.inset) + .overflow_hidden() + .child( + canvas( + move |bounds, _window, _cx| { + origin_cell.set(bounds.origin); + size_cell.set(bounds.size); + (snap, bounds) + }, + move |_bounds, (snap, bounds), window, cx| { + paint_sunburst(&snap, bounds, window, cx); + }, + ) + .flex_1() + .h_full(), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, ev: &MouseDownEvent, _, cx| { + let (lx, ly) = view.to_local(ev.position); + let Some(hit) = view.pick_sunburst(lx, ly) else { + view.treemap_hover = None; + cx.notify(); + return; + }; + // Center circle steps one level up the trail. + if hit == clusters::SunburstHit::Center { + if !view.trail.is_empty() { + view.go_parent(cx); + } else { + view.treemap_hover = None; + cx.notify(); + } + return; + } + let node = view.sunburst_hit_node(hit); + if ev.click_count >= 2 { + if let Some(i) = node { + if let Some(nd) = view.shown.nodes.get(i) { + let id = nd.id.clone(); + view.focus_node(&id, cx); + } + } + return; + } + view.selected = node; + view.treemap_hover = node; + cx.notify(); + }), + ) + .on_mouse_move(cx.listener(|view, ev: &MouseMoveEvent, _, cx| { + let (lx, ly) = view.to_local(ev.position); + let h = view + .pick_sunburst(lx, ly) + .and_then(|hit| view.sunburst_hit_node(hit)); + if h != view.treemap_hover { + view.treemap_hover = h; + cx.notify(); + } + })) + } } impl Render for RootView { @@ -1795,6 +1922,7 @@ impl Render for RootView { let center = match self.view_mode { ViewMode::Graph => self.render_graph_canvas(theme, cx), ViewMode::Treemap => self.render_treemap_canvas(theme, cx), + ViewMode::Sunburst => self.render_sunburst_canvas(theme, cx), }; let insights = if self.right_open { Some(self.render_insights(theme, cx)) @@ -2656,3 +2784,317 @@ fn paint_tree_text( } let _ = shaped.paint(point(px(x), px(y)), px(14.), window, cx); } + +/// Polygon for a sunburst wedge: outer arc sampled forward, inner arc back. +/// Canvas-local points (the caller offsets by the paint origin). +fn sunburst_polygon(w: &clusters::SunburstWedge, cx: f32, cy: f32) -> Vec> { + let steps = ((w.span() / 0.06).ceil() as usize).clamp(3, 64); + let mut pts = Vec::with_capacity(2 * (steps + 1)); + for i in 0..=steps { + let a = w.start + w.span() * i as f32 / steps as f32; + pts.push(point( + px(cx + w.outer * a.cos()), + px(cy + w.outer * a.sin()), + )); + } + for i in (0..=steps).rev() { + let a = w.start + w.span() * i as f32 / steps as f32; + pts.push(point( + px(cx + w.inner * a.cos()), + px(cy + w.inner * a.sin()), + )); + } + pts +} + +/// Bostock-style tint: members share their community hue, fading lighter +/// as member rank falls so adjacent arcs stay distinguishable. +fn member_shade(base: Hsla, rank: usize, total: usize) -> Hsla { + let t = if total > 1 { + rank as f32 / (total - 1) as f32 + } else { + 0.0 + }; + Hsla { + h: base.h, + s: (base.s * (1.0 - 0.25 * t)).max(0.0), + l: (base.l + 0.14 * t).min(0.92), + a: base.a, + } +} + +/// Sunburst of the Leiden communities: the same hierarchy as the treemap, +/// radial instead of rectangular. The center disc is the root of the +/// displayed graph; the inner ring holds one arc per community and the +/// outer ring fans out community members, each slice proportional to its +/// PageRank weight. Clicking the center steps one level up the drill-down +/// trail. Wedges are painted as filled arc polygons (not composed +/// elements), with selection/hover as stroked outlines on top. +fn paint_sunburst(snap: &TreeSnapshot, bounds: Bounds, window: &mut Window, cx: &mut App) { + let vw = f32::from(bounds.size.width); + let vh = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let font_size = px(snap.label_font_size); + + let (mcx, mcy, radius) = clusters::sunburst_frame(vw, vh); + let wedges = clusters::layout_sunburst(&snap.tree, radius); + // Canvas-local → absolute. + let placed: Vec>> = wedges + .iter() + .map(|w| { + sunburst_polygon(w, mcx, mcy) + .into_iter() + .map(|p| point(px(f32::from(p.x) + ox), px(f32::from(p.y) + oy))) + .collect() + }) + .collect(); + if wedges.is_empty() { + let text: SharedString = if snap.tree.is_empty() { + snap.summary.clone().into() + } else { + "Sunburst arcs too small — load a smaller graph or resize.".into() + }; + let run = TextRun { + len: text.len(), + font: font(&snap.label_font), + color: snap.theme.secondary, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, font_size, &[run], None); + let w = f32::from(shaped.width); + let h = f32::from(shaped.ascent + shaped.descent); + let _ = shaped.paint( + point(px(ox + (vw - w) / 2.0), px(oy + (vh - h) / 2.0)), + px(14.), + window, + cx, + ); + return; + } + + // Root disc: the whole displayed graph, with an accent rim. This is + // what the center-click "up" affordance points at. + let root_r = radius * clusters::SUNBURST_CENTER_FRAC; + let mut disc_pts = Vec::with_capacity(65); + for k in 0..=64 { + let a = k as f32 / 64.0 * std::f32::consts::TAU; + disc_pts.push(point( + px(ox + mcx + root_r * a.cos()), + px(oy + mcy + root_r * a.sin()), + )); + } + let mut disc_path = PathBuilder::fill(); + disc_path.add_polygon(&disc_pts, true); + if let Ok(path) = disc_path.build() { + window.paint_path(path, snap.theme.surface); + } + let mut disc_rim = PathBuilder::stroke(px(1.5)); + disc_rim.add_polygon(&disc_pts, true); + if let Ok(path) = disc_rim.build() { + window.paint_path(path, snap.theme.accent); + } + paint_centered_text( + snap, + snap.theme.foreground, + &snap.center_label, + ox + mcx, + oy + mcy, + root_r * 1.6, + window, + cx, + ); + + // Fills first, then separators, then selection strokes, so rings never + // cover their outlines. Member rank within each community drives the + // tint gradient (layout emits members heaviest-first). + let mut member_rank = vec![0usize; snap.tree.len()]; + let mut strokes: Vec<(u8, usize, f32, Hsla)> = Vec::new(); + for (i, w) in wedges.iter().enumerate() { + let base = node_color(&snap.theme, w.community); + let fill = match w.node { + None => base, + Some(_) => { + let total = snap + .tree + .get(w.community) + .map(|c| c.members.len()) + .unwrap_or(1); + let rank = member_rank[w.community].min(total.saturating_sub(1)); + member_rank[w.community] += 1; + member_shade(base, rank, total) + } + }; + let mut fill_path = PathBuilder::fill(); + fill_path.add_polygon(&placed[i], true); + if let Ok(path) = fill_path.build() { + window.paint_path(path, fill); + } + let node_here = w.node.filter(|n| Some(*n) == snap.hover_node); + let stroke = if w.node.is_some() && w.node == snap.selected_node { + Some((3u8, i, 2.0, snap.theme.bright)) + } else if node_here.is_some() { + Some((2, i, 1.0, snap.theme.bright)) + } else if Some(w.community) == snap.selected_community { + Some((1, i, 1.5, highlight(&snap.theme))) + } else { + None + }; + if let Some(s) = stroke { + strokes.push(s); + } + } + // Hairline separators between slices, like the reference's gaps. + for pts in &placed { + let mut sep = PathBuilder::stroke(px(1.)); + sep.add_polygon(pts, true); + if let Ok(path) = sep.build() { + window.paint_path(path, snap.theme.inset); + } + } + strokes.sort_by_key(|(rank, ..)| *rank); + for (_, i, width, color) in strokes { + let mut stroke_path = PathBuilder::stroke(px(width)); + stroke_path.add_polygon(&placed[i], true); + if let Ok(path) = stroke_path.build() { + window.paint_path(path, color); + } + } + + // Labels, centered on each wedge: every readable community arc, then + // the largest member arcs first so the cap keeps informative names. + for w in wedges.iter().filter(|w| w.node.is_none()) { + if w.arc_len() < 48.0 { + continue; + } + let count = snap + .tree + .get(w.community) + .map(|c| c.members.len()) + .unwrap_or(0); + let label = format!("C{} · {count}", w.community + 1); + let a = w.mid_angle(); + let r = w.mid_radius(); + paint_centered_text( + snap, + snap.theme.bright, + &label, + ox + mcx + r * a.cos(), + oy + mcy + r * a.sin(), + w.arc_len().min((w.outer - w.inner) * 2.0), + window, + cx, + ); + } + let mut members: Vec<&clusters::SunburstWedge> = + wedges.iter().filter(|w| w.node.is_some()).collect(); + members.sort_by(|a, b| { + b.arc_len() + .partial_cmp(&a.arc_len()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for w in members.into_iter().take(60) { + if w.arc_len() < 56.0 { + continue; + } + let Some(i) = w.node else { continue }; + let Some(name) = snap.names.get(i) else { + continue; + }; + let a = w.mid_angle(); + let r = w.mid_radius(); + paint_centered_text( + snap, + snap.theme.bright, + name, + ox + mcx + r * a.cos(), + oy + mcy + r * a.sin(), + w.arc_len() - 8.0, + window, + cx, + ); + } + + // Summary pill, bottom-left over the disc. + let text: SharedString = snap.summary.clone().into(); + let run = TextRun { + len: text.len(), + font: font(&snap.label_font), + color: snap.theme.secondary, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, font_size, &[run], None); + let w = f32::from(shaped.width); + let h = f32::from(shaped.ascent + shaped.descent); + let pad_x = 6.0; + let pad_y = 3.0; + window.paint_quad(PaintQuad { + bounds: Bounds::new( + point(px(ox + 8.0), px(oy + vh - h - pad_y * 2.0 - 8.0)), + size(px(w + pad_x * 2.0), px(h + pad_y * 2.0)), + ), + corner_radii: Corners::all(px(4.)), + background: snap.theme.inset.opacity(0.88).into(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::Solid, + }); + let _ = shaped.paint( + point( + px(ox + 8.0 + pad_x), + px(oy + vh - h - pad_y * 2.0 - 8.0 + pad_y), + ), + px(14.), + window, + cx, + ); +} + +/// One sunburst label, centered on its wedge and painted only when the +/// shaped text fits — nothing may bleed into the neighbour. +#[allow(clippy::too_many_arguments)] +fn paint_centered_text( + snap: &TreeSnapshot, + color: Hsla, + label: &str, + cx_px: f32, + cy_px: f32, + max_w: f32, + window: &mut Window, + cx: &mut App, +) { + if max_w < 20.0 { + return; + } + let text: SharedString = label.to_string().into(); + let run = TextRun { + len: text.len(), + font: font(&snap.label_font), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, px(snap.label_font_size), &[run], None); + let w = f32::from(shaped.width); + let h = f32::from(shaped.ascent + shaped.descent); + if w > max_w { + return; + } + let _ = shaped.paint( + point(px(cx_px - w / 2.0), px(cy_px - h / 2.0)), + px(14.), + window, + cx, + ); +} From c76ffce6107e8bb8b011c73994795d3d979cabcd Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Fri, 2 Oct 2026 10:24:03 -0700 Subject: [PATCH 4/6] Sunburst: weight-driven member reach (jagged edge) - Member arcs extend by absolute weight (sqrt vs global max, stub floor) instead of filling a perfect disc; angles still encode share; uniform weights stay circular - Reach-tracking unit test, README note --- README.md | 2 +- src/clusters.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6cd9b66..78dfbd6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This is the native port of [bugscope-tauri](https://github.com/LadybugDB/bugscop - **Breadcrumbs + dot-commands** - Drill-down trail under the header (`Root › A › B`, cached jumps) plus query-box `.root` / `.parent` (`.up`/`.back`) and `.schema` / `.data` (schema view without the menu). - **Schema View** - Header `Data`/`Schema` toggle (or `.schema` / `.data` in the query box, `File → Toggle Schema View` menu) to switch between node tables and the edge graph. The breadcrumb badge (`schema ✕`) is also a one-click way back to data. - **Live Layout** - Force simulation (repulsion + springs + damping) runs at 30 Hz and settles when the layout goes quiet; pause/resume any time. -- **Leiden Treemap + Sunburst** - Header toggle (or `⌘1`/`⌘2`/`⌘3`) switches the canvas between the graph, a squarified treemap of Leiden communities, and a classic sunburst of the same communities (in-process icebug Leiden, same family as `GDS_LEIDEN`): the center disc is the root of the displayed graph, rings move outward with hierarchy depth (communities, then members), and every slice is proportional to PageRank weight with members tinted from their community hue. Double-click drills into a neighborhood; clicking the sunburst center steps one level up the trail. At startup, graphs with more than 64 edges open in the treemap (smaller ones in the graph view); the choice then sticks — navigating never switches views, only the header toggle, menu, or `⌘1`/`⌘2`/`⌘3` do. +- **Leiden Treemap + Sunburst** - Header toggle (or `⌘1`/`⌘2`/`⌘3`) switches the canvas between the graph, a squarified treemap of Leiden communities, and a classic sunburst of the same communities (in-process icebug Leiden, same family as `GDS_LEIDEN`): the center disc is the root of the displayed graph, rings move outward with hierarchy depth (communities, then members), slice angles are proportional to PageRank share while each member's reach encodes its absolute weight — heavy members stick out, so the outer edge reads jagged rather than a perfect disc. Double-click drills into a neighborhood; clicking the sunburst center steps one level up the trail. At startup, graphs with more than 64 edges open in the treemap (smaller ones in the graph view); the choice then sticks — navigating never switches views, only the header toggle, menu, or `⌘1`/`⌘2`/`⌘3` do. - **Insights Pane** - Collapsible right pane with the top 10 nodes by PageRank plus the Leiden community summary; clicking a row focuses (PageRank) or selects (community) it. ## Usage diff --git a/src/clusters.rs b/src/clusters.rs index 9ab7e66..85fa6a9 100644 --- a/src/clusters.rs +++ b/src/clusters.rs @@ -366,8 +366,14 @@ pub fn sunburst_frame(vw: f32, vh: f32) -> (f32, f32, f32) { pub const SUNBURST_CENTER_FRAC: f32 = 0.30; /// Lay out `communities` as sunburst wedges for a `radius`-px sunburst. -/// Member arcs narrower than ~2px at their mid radius are dropped — they -/// can neither be read nor hit, like sub-`min_tile` treemap cells. +/// +/// Angles encode each slice's share (community share of the whole, member +/// share of its community), while each member arc's outer radius encodes +/// its absolute weight (square-root scaled against the heaviest member in +/// the graph). Heavy members stick out past their neighbours, so the +/// outer edge reads jagged instead of a perfect disc. Member arcs narrower +/// than ~2px at their mid radius are dropped — they can neither be read +/// nor hit, like sub-`min_tile` treemap cells. pub fn layout_sunburst(communities: &[Community], radius: f32) -> Vec { let mut wedges = Vec::new(); if communities.is_empty() || radius <= 0.0 { @@ -379,6 +385,13 @@ pub fn layout_sunburst(communities: &[Community], radius: f32) -> Vec 0.0 { @@ -414,13 +427,16 @@ pub fn layout_sunburst(communities: &[Community], radius: f32) -> Vec = wedges.iter().filter(|w| w.node.is_some()).collect(); + assert_eq!(members.len(), 3); + let heavy = members.iter().find(|w| w.node == Some(0)).unwrap(); + assert!((heavy.outer - 250.0 * 0.97).abs() < 1e-3); + for m in members.iter().filter(|w| w.node != Some(0)) { + assert!(m.outer < heavy.outer - 1.0, "weak falls short"); + assert!(m.outer > m.inner, "stub floor keeps it visible"); + } + // Uniform weights → uniform reach (a true disc). + let cs = build_communities(4, &[0, 0, 1, 1], None); + let wedges = layout_sunburst(&cs, 250.0); + let outers: Vec = wedges + .iter() + .filter(|w| w.node.is_some()) + .map(|w| w.outer) + .collect(); + assert!(!outers.is_empty()); + for o in &outers { + assert!((o - outers[0]).abs() < 1e-3); + } + } + #[test] fn sunburst_drops_unreadable_members() { // One heavy node + 200 dust nodes: dust arcs are sub-pixel. From e05c210851b22196f5df4d578cb52cdfee8882f9 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Fri, 2 Oct 2026 10:41:50 -0700 Subject: [PATCH 5/6] Resolve algo extension deps from well-known paths, no DYLD_* needed - Bake absolute Homebrew/system LC_RPATHs into macOS binaries (.cargo/config.toml) and the staged .app (stage_macos_bundle.sh, idempotent): the runtime-downloaded extension's own rpaths point at its build machine, so its @rpath deps expand against ours - CI gate asserting the staged binary carries those rpaths - packaging/homebrew/bugscope.rb formula (libexec layout matching the baked @executable_path rpaths) + README troubleshooting note --- .cargo/config.toml | 17 +++++++++++ .github/workflows/build.yml | 18 ++++++++++++ README.md | 1 + packaging/homebrew/bugscope.rb | 53 ++++++++++++++++++++++++++++++++++ scripts/stage_macos_bundle.sh | 28 ++++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 packaging/homebrew/bugscope.rb diff --git a/.cargo/config.toml b/.cargo/config.toml index a088098..4fcaf44 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -20,8 +20,19 @@ ICEBUG_DIR = { value = "icebug", relative = true } # - macOS .app bundles stage them under Contents/Frameworks # - cargo dev/test binaries resolve ../../../ entries # (target/{debug,release}/deps/ -> repo root) +# Absolute well-known rpaths (macOS only): the algo extension is downloaded +# at runtime and its own LC_RPATHs point at its build machine, so its +# @rpath deps (libarrow, libomp, libnetworkit) expand against *this* +# binary's rpaths. These entries let it resolve from Homebrew / system +# locations with no DYLD_LIBRARY_PATH. Missing dirs are harmless. [target.aarch64-apple-darwin] rustflags = [ + "-C", "link-arg=-Wl,-rpath,/opt/homebrew/lib", + "-C", "link-arg=-Wl,-rpath,/opt/homebrew/opt/apache-arrow/lib", + "-C", "link-arg=-Wl,-rpath,/opt/homebrew/opt/libomp/lib", + "-C", "link-arg=-Wl,-rpath,/usr/local/lib", + "-C", "link-arg=-Wl,-rpath,/usr/local/opt/apache-arrow/lib", + "-C", "link-arg=-Wl,-rpath,/usr/local/opt/libomp/lib", "-C", "link-arg=-Wl,-rpath,@executable_path", "-C", "link-arg=-Wl,-rpath,@executable_path/icebug/lib", "-C", "link-arg=-Wl,-rpath,@executable_path/liblbug", @@ -37,6 +48,12 @@ rustflags = [ [target.x86_64-apple-darwin] rustflags = [ + "-C", "link-arg=-Wl,-rpath,/opt/homebrew/lib", + "-C", "link-arg=-Wl,-rpath,/opt/homebrew/opt/apache-arrow/lib", + "-C", "link-arg=-Wl,-rpath,/opt/homebrew/opt/libomp/lib", + "-C", "link-arg=-Wl,-rpath,/usr/local/lib", + "-C", "link-arg=-Wl,-rpath,/usr/local/opt/apache-arrow/lib", + "-C", "link-arg=-Wl,-rpath,/usr/local/opt/libomp/lib", "-C", "link-arg=-Wl,-rpath,@executable_path", "-C", "link-arg=-Wl,-rpath,@executable_path/icebug/lib", "-C", "link-arg=-Wl,-rpath,@executable_path/liblbug", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19dccfb..7e15e84 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -376,6 +376,24 @@ jobs: $hash = (Get-FileHash "dist/$name.zip" -Algorithm SHA256).Hash.ToLower() [IO.File]::WriteAllText("dist/$name.zip.sha256", "$hash $name.zip`n") + - name: Assert macOS well-known rpaths + if: matrix.platform == 'macos-latest' + shell: bash + run: | + # The runtime-downloaded algo extension resolves its @rpath deps + # against this binary's LC_RPATHs: fail if the Homebrew / system + # fallbacks are missing (no DYLD_LIBRARY_PATH in the field). + rpaths="$(otool -l dist/Bugscope.app/Contents/MacOS/bugscope | grep -A 2 LC_RPATH | grep 'path ')" + echo "$rpaths" + missing=0 + for want in /opt/homebrew/lib /opt/homebrew/opt/apache-arrow/lib /opt/homebrew/opt/libomp/lib /usr/local/lib; do + if ! echo "$rpaths" | grep -Fq "path $want "; then + echo "missing LC_RPATH: $want" >&2 + missing=1 + fi + done + exit $missing + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 78dfbd6..dd2668e 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,4 @@ Summary-space PageRank sidecars, LLM cluster naming, Voronoi overlay, the lever - Window doesn't appear on launch - older versions scanned `$HOME` recursively on the UI thread before first paint and hung. Current versions only scan the working directory; update and retry. - `Load failed: ...` in the status bar - the picked file isn't a readable LadybugDB database, or it's locked by another process. - Search returns nothing - search scans node `name`/`title`/`label`/`id` plus all properties (first 50k nodes, 50 results); check the query box text and the selected database. +- GDS_* / `LOAD algo` fails with `Failed to load library` - the binary resolves the extension's `@rpath` deps from its bundled `Frameworks` plus Homebrew/system locations, so no `DYLD_LIBRARY_PATH` is needed. If it still fails, the cached extension itself is stale: `INSTALL algo` serves a versioned build under `~/.lbdb/extension//`, and a build for an older ladybug (e.g. `0.20.0` symbols vs a `0.21.x` `liblbug`) fails to load no matter the paths. Point `BUGSCOPE_ALGO_EXTENSION` at a matching `libalgo.lbug_extension` (a `LOAD EXTENSION` of the file must print no `symbol not found` under plain `dlopen`), and report the version skew upstream — the extension is built and published elsewhere. diff --git a/packaging/homebrew/bugscope.rb b/packaging/homebrew/bugscope.rb new file mode 100644 index 0000000..806bf14 --- /dev/null +++ b/packaging/homebrew/bugscope.rb @@ -0,0 +1,53 @@ +# Homebrew formula for bugscope (native LadybugDB graph visualizer). +# +# Install from this tap: +# brew tap /bugscope # with this file at Formula/bugscope.rb +# brew install bugscope +# +# Layout: the binary lives in libexec next to icebug/lib + liblbug, so the +# baked @executable_path rpaths (.cargo/config.toml) resolve with no env +# vars; `bin` gets a symlink. The runtime-downloaded algo extension +# (INSTALL algo on first GDS_* use) resolves its @rpath deps via the +# binary's LC_RPATHs, which also cover HOMEBREW_PREFIX lib dirs below. +# +# TODO: fill in `url`/`sha256` per release and the repo license. +class Bugscope < Formula + desc "Native graph visualizer for LadybugDB" + homepage "https://github.com/LadybugDB/bugscope" + url "https://github.com/LadybugDB/bugscope/archive/refs/tags/v0.1.0.tar.gz" + sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" + # license "REPLACE_WITH_REPO_LICENSE" + + depends_on "cmake" => :build + depends_on "pkg-config" => :build + depends_on "rust" => :build + depends_on "apache-arrow" + depends_on "libomp" + depends_on "openssl@3" + + def install + # Prebuilt Networkit + shared liblbug (the algo extension dlopens + # against the shared ladybug symbols at LOAD time). + system "bash", "scripts/download_icebug.sh" + system "bash", "scripts/download-liblbug.sh" + # Vendor brew arrow/omp next to libnetworkit for version-pinned loads. + system "bash", "scripts/vendor_arrow.sh" + + system "cargo", "build", "--release", "--locked" + + libexec.install "target/release/bugscope" + libexec.install "icebug" => "icebug" + libexec.install "liblbug" => "liblbug" + # Belt and braces next to the baked-in rpaths: the install prefix and + # the brewed arrow/omp locations, so relocation never breaks @rpath. + for rpath in [libexec/"icebug/lib", libexec/"liblbug", HOMEBREW_PREFIX/"lib", + HOMEBREW_PREFIX/"opt/apache-arrow/lib", HOMEBREW_PREFIX/"opt/libomp/lib"] do + quiet_system "install_name_tool", "-add_rpath", rpath, libexec/"bugscope" + end + bin.install_symlink libexec/"bugscope" + end + + test do + assert_match "Usage", shell_output("#{bin}/bugscope --help") + end +end diff --git a/scripts/stage_macos_bundle.sh b/scripts/stage_macos_bundle.sh index 87958fa..868baad 100755 --- a/scripts/stage_macos_bundle.sh +++ b/scripts/stage_macos_bundle.sh @@ -71,6 +71,26 @@ change_if_present() { fi } +# Well-known system/Homebrew locations, so the runtime-downloaded algo +# extension (whose own LC_RPATHs point at its build machine) resolves its +# @rpath deps with no DYLD_LIBRARY_PATH. Absolute LC_RPATH entries are +# honored by dyld and harmless when the dir is absent. +WELL_KNOWN_RPATHS=( + /opt/homebrew/lib + /opt/homebrew/opt/apache-arrow/lib + /opt/homebrew/opt/libomp/lib + /usr/local/lib + /usr/local/opt/apache-arrow/lib + /usr/local/opt/libomp/lib +) + +add_rpath_if_missing() { + local file="$1" rpath="$2" + if ! otool -l "$file" | grep -A 2 LC_RPATH | grep -Fq "path $rpath "; then + install_name_tool -add_rpath "$rpath" "$file" + fi +} + patch_binary() { local binary="$1" while IFS= read -r dep; do @@ -89,6 +109,14 @@ for dylib in "$FRAMEWORKS_DIR"/*.dylib; do install_name_tool -id "@rpath/$(basename "$dylib")" "$dylib" patch_binary "$dylib" done +# The shipped binary (and each bundled dylib, for transitive deps) also +# searches Homebrew / system locations. +for rpath in "${WELL_KNOWN_RPATHS[@]}"; do + add_rpath_if_missing "$BINARY" "$rpath" + for dylib in "$FRAMEWORKS_DIR"/*.dylib; do + add_rpath_if_missing "$dylib" "$rpath" + done +done chmod -w "$FRAMEWORKS_DIR"/*.dylib 2>/dev/null || true echo "Staged macOS bundle in $APP_DIR" From ef780db17eec10e8c7ef9dc79b562009d057756f Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Fri, 2 Oct 2026 10:55:32 -0700 Subject: [PATCH 6/6] Log backend failures to stderr; document stale-extension open failure - Load/query failures now eprintln (window status is invisible headless) - Comment + README: a DB that once ran LOAD replays it from WAL on every open, so a symbol-skewed cached extension bricks the file; paths/rpaths can't fix that, replace the cached build --- README.md | 2 +- src/backend.rs | 6 ++++++ src/ui.rs | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dd2668e..9f91e5d 100644 --- a/README.md +++ b/README.md @@ -124,4 +124,4 @@ Summary-space PageRank sidecars, LLM cluster naming, Voronoi overlay, the lever - Window doesn't appear on launch - older versions scanned `$HOME` recursively on the UI thread before first paint and hung. Current versions only scan the working directory; update and retry. - `Load failed: ...` in the status bar - the picked file isn't a readable LadybugDB database, or it's locked by another process. - Search returns nothing - search scans node `name`/`title`/`label`/`id` plus all properties (first 50k nodes, 50 results); check the query box text and the selected database. -- GDS_* / `LOAD algo` fails with `Failed to load library` - the binary resolves the extension's `@rpath` deps from its bundled `Frameworks` plus Homebrew/system locations, so no `DYLD_LIBRARY_PATH` is needed. If it still fails, the cached extension itself is stale: `INSTALL algo` serves a versioned build under `~/.lbdb/extension//`, and a build for an older ladybug (e.g. `0.20.0` symbols vs a `0.21.x` `liblbug`) fails to load no matter the paths. Point `BUGSCOPE_ALGO_EXTENSION` at a matching `libalgo.lbug_extension` (a `LOAD EXTENSION` of the file must print no `symbol not found` under plain `dlopen`), and report the version skew upstream — the extension is built and published elsewhere. +- Opening a file fails with `Failed to load library: .../.lbdb/extension//.../libalgo.lbug_extension` - that DB ran `LOAD algo` before, which ladybug WAL-logs and replays on every open, so one stale cached build bricks the file. Library paths are not the cause (the binary resolves `@rpath` deps from its bundled `Frameworks` plus Homebrew/system locations, no `DYLD_LIBRARY_PATH` needed): it is symbol skew between the cached build and `liblbug` (check with plain `dlopen` — `symbol not found in flat namespace` means skew, `Library not loaded` means paths). Fix: back up, then replace the named cached file with a symbol-matching build (a plain `LOAD EXTENSION` of that file must succeed); `INSTALL algo` may re-serve the stale build, so avoid re-running it until upstream publishes matching bits. `BUGSCOPE_ALGO_EXTENSION` only affects post-open `LOAD`, not WAL replay. diff --git a/src/backend.rs b/src/backend.rs index 3f9487a..a752167 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -152,6 +152,12 @@ fn graph_node_from_value(val: &Value) -> Option { } pub fn open_connection(path: &str) -> Result> { + // NOTE: if this DB ever ran `LOAD `, ladybug WAL-logs it and + // replays the load on every open — a stale cached extension build + // (symbol skew vs liblbug) then fails EVERY open of that file, and no + // path/rpath change can fix it. The failing cache path is in the error; + // replacing it with a symbol-matching build unblocks the file. See + // README troubleshooting. let db = Database::new(path, SystemConfig::default()) .with_context(|| format!("failed to open database {path}"))?; // Leak the Database so the Connection can outlive this call, mirroring the diff --git a/src/ui.rs b/src/ui.rs index 5daf9b4..c054409 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -333,7 +333,11 @@ impl RootView { self.refresh_analytics(); self.set_status(msg, cx); } - Err(e) => self.set_status(format!("Load failed: {e:#}"), cx), + Err(e) => { + // Also stderr: window status is invisible on headless runs. + eprintln!("bugscope: load failed: {e:#}"); + self.set_status(format!("Load failed: {e:#}"), cx) + } } } @@ -882,7 +886,10 @@ impl RootView { self.set_status(format!("Cypher: {nn} nodes, {ne} edges"), cx); } } - Err(e) => self.set_status(format!("Query failed: {e:#}"), cx), + Err(e) => { + eprintln!("bugscope: query failed: {e:#}"); + self.set_status(format!("Query failed: {e:#}"), cx) + } } }