node-based graph editor
git clone https://git.lucas.co/cce-graph.git
feat: cce-graph --vault, the notes vault as a link graph (Obsidian-on-cce M4)
A separate Application that main runs for `--vault [DIR] [--local
[NOTE]]`; the project editor is untouched.
- linkgraph: a node per note plus a ghost per unresolved link target, one
edge per linked pair, built from cce-vault's index and rebuilt on each
watcher batch keeping positions and pins. Force layout with repulsion
through a spatial grid, springs and a centering pull, stepped while it
cools; settled, the app goes idle
- global and local views; the local graph is N hops (1-4) around the note
open in cce-notes, polled as `current` on cce-notes' socket each second
- a click opens the note in cce-notes (its socket, or a launch); drag
pins, right-click unpins; hover lights a node's neighbourhood
- filter: names, #tag / tag:, path:; labels fade in with zoom
- single instance on cce-graph-vault-<display>.sock; Vault Graph desktop
entry
Verified in a scale-1 shadow with cce-notes, against a copy of the vault
plus 80 generated linked notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 32 ++
Cargo.toml | 4 +
cce-graph-vault.desktop | 10 +
src/linkgraph.rs | 460 ++++++++++++++++++++++++
src/main.rs | 9 +
src/vault.rs | 923 ++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1438 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7fb640b..850d5aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,38 @@ here — this crate is orchestration: menu bar + File/Edit/View dropdowns, the `
widget, an image overlay, and a floating "control panel" showing the selected
node/image.
+## Vault mode (`cce-graph --vault`)
+
+A second, separate `Application` (`src/vault.rs`, model in
+`src/linkgraph.rs`): the notes vault as a link graph, milestone 4 of the
+Obsidian-on-cce plan. `main` runs it when the arguments carry `--vault`
+(`cce-graph --vault [DIR] [--local [NOTE]]`; DIR defaults to the vault in
+config.kdl, as cce-notes reads it). Nothing it does touches the project
+editor.
+
+- **Model:** a node per note plus a grey ghost per link target that does
+ not exist; one edge per linked pair. Built from `cce_vault::Index`, and
+ rebuilt on every watcher batch keeping positions and pins.
+- **Layout:** force-directed (repulsion through a spatial grid of
+ `REPULSE_RANGE` cells, springs, a centering pull), stepped in `tick`
+ while `alpha` cools; once settled the app goes idle. The camera fits
+ once the layout has spread and again when it settles, unless the user
+ has panned or zoomed.
+- **Local graph:** N hops (Depth chip, Ctrl+=/−) around the note open in
+ cce-notes, polled once a second as `current` on cce-notes' instance
+ socket (`idle_poll_interval` runs only in local mode). A click on a node
+ sends `open <abs path>` over that socket, or launches cce-notes.
+- **Single instance** on `/tmp/cce-graph-vault-<display>.sock`; a second
+ launch forwards `local [note]` / `global` and exits — cce-notes' Ctrl+G
+ relies on that.
+- **Filter box:** words match names, `#tag` / `tag:` tags (nested too),
+ `path:` paths. Drag pins a node, right-click unpins.
+- Labels fade in from zoom 1.1; the hovered node's neighbourhood is lit
+ and labelled whatever the zoom.
+
+`cargo test -p cce-graph` covers the model (build, ghosts, hops, the layout
+settling with links short, filters, hit-testing) and the editor's wiring.
+
## Build and run
```sh
diff --git a/Cargo.toml b/Cargo.toml
index f98810a..24e03c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,4 +15,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
image = { version = "0.25.10", features = ["png", "jpeg"] }
kdl = "4.6"
+cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba" }
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/cce-graph-vault.desktop b/cce-graph-vault.desktop
new file mode 100644
index 0000000..61068da
--- /dev/null
+++ b/cce-graph-vault.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Vault Graph
+Comment=The notes vault as a graph of links
+Exec=cce-graph --vault
+Icon=cce-graph
+Categories=Office;Utility;
+Terminal=false
+StartupNotify=true
+StartupWMClass=cce-graph-vault
diff --git a/src/linkgraph.rs b/src/linkgraph.rs
new file mode 100644
index 0000000..238bce7
--- /dev/null
+++ b/src/linkgraph.rs
@@ -0,0 +1,460 @@
+//! The vault as a link graph: one node per note (plus a ghost per link
+//! target that does not exist yet, as Obsidian shows them), an edge per
+//! pair of notes linked either way, and a force layout that cools and
+//! stops so the app goes idle once the picture settles.
+//!
+//! Pure model, no drawing: positions are world units, the app owns the
+//! camera. Kept apart from cce-ui's `Graph` widget on purpose — ports,
+//! grid snapping and name-keyed wires are node-editor semantics, and a
+//! vault of thousands of notes needs id-keyed edges and a spatial grid.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+
+use cce_vault::index::stem;
+use cce_vault::{FileKind, Index};
+
+/// The rest length of a link's spring.
+const LINK_LEN: f32 = 90.0;
+const LINK_K: f32 = 0.08;
+const REPULSE: f32 = 9000.0;
+/// Repulsion only reaches this far; past it the centering pull dominates
+/// anyway. It is also the spatial grid's cell size.
+const REPULSE_RANGE: f32 = 300.0;
+const CENTER_K: f32 = 0.006;
+const DAMPING: f32 = 0.55;
+const COOLING: f32 = 0.985;
+/// Below this temperature the layout is settled and stops stepping.
+const SETTLED: f32 = 0.004;
+
+#[derive(Clone, Debug)]
+pub struct Node {
+ /// The note's vault path; for a ghost, the link text that resolves
+ /// nowhere.
+ pub path: String,
+ pub name: String,
+ pub ghost: bool,
+ pub tags: Vec<String>,
+ pub pos: (f32, f32),
+ vel: (f32, f32),
+ /// Held where the user dropped it.
+ pub pinned: bool,
+}
+
+#[derive(Default)]
+pub struct LinkGraph {
+ pub nodes: Vec<Node>,
+ pub edges: Vec<(usize, usize)>,
+ adj: Vec<Vec<usize>>,
+ by_path: HashMap<String, usize>,
+ /// The layout's temperature: forces scale with it, and it decays.
+ pub alpha: f32,
+}
+
+impl LinkGraph {
+ /// Build from the index. Nodes that were in `prev` keep their place;
+ /// new ones start next to a neighbour already placed, or on a spiral.
+ pub fn build(ix: &Index, prev: Option<&LinkGraph>) -> LinkGraph {
+ let mut g = LinkGraph::default();
+ let mut notes: Vec<&str> = ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, _)| p.as_str()).collect();
+ notes.sort();
+ for p in notes {
+ let tags = ix.note(p).map(|n| n.tags.iter().map(|t| t.name.to_lowercase()).collect()).unwrap_or_default();
+ g.add(Node { path: p.to_string(), name: stem(p).to_string(), ghost: false, tags, pos: (0.0, 0.0), vel: (0.0, 0.0), pinned: false });
+ }
+ let mut seen: HashSet<(usize, usize)> = HashSet::new();
+ let count = g.nodes.len();
+ for i in 0..count {
+ let from = g.nodes[i].path.clone();
+ for (link, target) in ix.outgoing(&from) {
+ let j = match target {
+ Some(t) => match g.by_path.get(t) {
+ Some(&j) => j,
+ // A link to an attachment or canvas: not drawn.
+ None => continue,
+ },
+ None => {
+ let key = format!("?{}", link.target.to_lowercase());
+ match g.by_path.get(&key) {
+ Some(&j) => j,
+ None => g.add(Node {
+ path: key,
+ name: link.target.clone(),
+ ghost: true,
+ tags: Vec::new(),
+ pos: (0.0, 0.0),
+ vel: (0.0, 0.0),
+ pinned: false,
+ }),
+ }
+ }
+ };
+ if i != j && seen.insert((i.min(j), i.max(j))) {
+ g.edges.push((i.min(j), i.max(j)));
+ }
+ }
+ }
+ g.adj = vec![Vec::new(); g.nodes.len()];
+ for &(a, b) in &g.edges {
+ g.adj[a].push(b);
+ g.adj[b].push(a);
+ }
+ g.place(prev);
+ g.alpha = 1.0;
+ g
+ }
+
+ fn add(&mut self, node: Node) -> usize {
+ let i = self.nodes.len();
+ self.by_path.insert(node.path.clone(), i);
+ self.nodes.push(node);
+ i
+ }
+
+ fn place(&mut self, prev: Option<&LinkGraph>) {
+ let mut placed = vec![false; self.nodes.len()];
+ if let Some(prev) = prev {
+ for (i, n) in self.nodes.iter_mut().enumerate() {
+ if let Some(&j) = prev.by_path.get(&n.path) {
+ n.pos = prev.nodes[j].pos;
+ n.pinned = prev.nodes[j].pinned;
+ placed[i] = true;
+ }
+ }
+ }
+ // A golden-angle spiral spreads the rest without overlaps; a new
+ // note with a placed neighbour starts beside it instead.
+ for i in 0..self.nodes.len() {
+ if placed[i] {
+ continue;
+ }
+ let near = self.adj[i].iter().find(|&&j| placed[j]).map(|&j| self.nodes[j].pos);
+ let t = i as f32 * 2.399_963;
+ self.nodes[i].pos = match near {
+ Some((x, y)) => (x + 20.0 * t.cos(), y + 20.0 * t.sin()),
+ None => {
+ let r = 18.0 * (i as f32 + 1.0).sqrt();
+ (r * t.cos(), r * t.sin())
+ }
+ };
+ placed[i] = true;
+ }
+ }
+
+ pub fn len(&self) -> usize {
+ self.nodes.len()
+ }
+
+ pub fn index_of(&self, path: &str) -> Option<usize> {
+ self.by_path.get(path).copied()
+ }
+
+ pub fn neighbors(&self, i: usize) -> &[usize] {
+ &self.adj[i]
+ }
+
+ pub fn degree(&self, i: usize) -> usize {
+ self.adj[i].len()
+ }
+
+ /// Draw radius: grows with the number of links, as Obsidian's does.
+ pub fn radius(&self, i: usize) -> f32 {
+ 3.5 + (self.degree(i) as f32).sqrt() * 2.2
+ }
+
+ /// Every node within `hops` links of `center`.
+ pub fn within(&self, center: usize, hops: usize) -> Vec<bool> {
+ let mut seen = vec![false; self.nodes.len()];
+ let mut queue = VecDeque::from([(center, 0usize)]);
+ seen[center] = true;
+ while let Some((i, d)) = queue.pop_front() {
+ if d == hops {
+ continue;
+ }
+ for &j in &self.adj[i] {
+ if !seen[j] {
+ seen[j] = true;
+ queue.push_back((j, d + 1));
+ }
+ }
+ }
+ seen
+ }
+
+ pub fn reheat(&mut self, to: f32) {
+ self.alpha = self.alpha.max(to);
+ }
+
+ pub fn settled(&self) -> bool {
+ self.alpha < SETTLED
+ }
+
+ /// One step of the layout over the `visible` nodes (the others are
+ /// frozen and exert nothing). False once settled.
+ pub fn step(&mut self, visible: &[bool]) -> bool {
+ if self.settled() {
+ return false;
+ }
+ let n = self.nodes.len();
+ let mut force = vec![(0.0f32, 0.0f32); n];
+
+ // Repulsion between nodes sharing or neighbouring a grid cell.
+ let cell = |p: (f32, f32)| ((p.0 / REPULSE_RANGE).floor() as i32, (p.1 / REPULSE_RANGE).floor() as i32);
+ let mut grid: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
+ for i in (0..n).filter(|&i| visible[i]) {
+ grid.entry(cell(self.nodes[i].pos)).or_default().push(i);
+ }
+ let range2 = REPULSE_RANGE * REPULSE_RANGE;
+ for (&(cx, cy), members) in &grid {
+ for dx in -1..=1 {
+ for dy in -1..=1 {
+ let Some(others) = grid.get(&(cx + dx, cy + dy)) else { continue };
+ for &i in members {
+ for &j in others {
+ if j <= i {
+ continue;
+ }
+ let (pi, pj) = (self.nodes[i].pos, self.nodes[j].pos);
+ let (mut ddx, mut ddy) = (pi.0 - pj.0, pi.1 - pj.1);
+ let mut d2 = ddx * ddx + ddy * ddy;
+ if d2 > range2 {
+ continue;
+ }
+ if d2 < 0.01 {
+ // Coincident: nudge apart deterministically.
+ ddx = 0.1 * ((i as f32).sin() + 0.5);
+ ddy = 0.1 * ((j as f32).cos() + 0.5);
+ d2 = ddx * ddx + ddy * ddy;
+ }
+ let f = REPULSE / d2.max(25.0);
+ let d = d2.sqrt();
+ let (fx, fy) = (ddx / d * f, ddy / d * f);
+ force[i].0 += fx;
+ force[i].1 += fy;
+ force[j].0 -= fx;
+ force[j].1 -= fy;
+ }
+ }
+ }
+ }
+ }
+ // Springs along the links.
+ for &(a, b) in &self.edges {
+ if !(visible[a] && visible[b]) {
+ continue;
+ }
+ let (pa, pb) = (self.nodes[a].pos, self.nodes[b].pos);
+ let (dx, dy) = (pb.0 - pa.0, pb.1 - pa.1);
+ let d = (dx * dx + dy * dy).sqrt().max(0.01);
+ let f = (d - LINK_LEN) * LINK_K;
+ let (fx, fy) = (dx / d * f, dy / d * f);
+ force[a].0 += fx;
+ force[a].1 += fy;
+ force[b].0 -= fx;
+ force[b].1 -= fy;
+ }
+ // A gentle pull to the middle keeps islands and orphans in view.
+ let mut moving = false;
+ for i in (0..n).filter(|&i| visible[i]) {
+ let node = &mut self.nodes[i];
+ if node.pinned {
+ node.vel = (0.0, 0.0);
+ continue;
+ }
+ let (fx, fy) = (force[i].0 - node.pos.0 * CENTER_K, force[i].1 - node.pos.1 * CENTER_K);
+ node.vel.0 = (node.vel.0 + fx * self.alpha) * DAMPING;
+ node.vel.1 = (node.vel.1 + fy * self.alpha) * DAMPING;
+ // Cap a step, so a cold start cannot fling a node away.
+ let speed = (node.vel.0 * node.vel.0 + node.vel.1 * node.vel.1).sqrt();
+ if speed > 40.0 {
+ node.vel.0 *= 40.0 / speed;
+ node.vel.1 *= 40.0 / speed;
+ }
+ node.pos.0 += node.vel.0;
+ node.pos.1 += node.vel.1;
+ moving |= speed > 0.05;
+ }
+ self.alpha *= COOLING;
+ if !moving {
+ self.alpha = 0.0;
+ }
+ !self.settled()
+ }
+
+ /// The topmost visible node within its radius (plus `slop`) of a world
+ /// point.
+ pub fn hit(&self, visible: &[bool], x: f32, y: f32, slop: f32) -> Option<usize> {
+ let mut best: Option<(usize, f32)> = None;
+ for i in (0..self.nodes.len()).filter(|&i| visible[i]) {
+ let (px, py) = self.nodes[i].pos;
+ let d2 = (px - x).powi(2) + (py - y).powi(2);
+ let r = self.radius(i) + slop;
+ if d2 <= r * r && best.is_none_or(|(_, b)| d2 < b) {
+ best = Some((i, d2));
+ }
+ }
+ best.map(|(i, _)| i)
+ }
+
+ /// The bounds of the visible nodes, as (min, max).
+ pub fn bounds(&self, visible: &[bool]) -> Option<((f32, f32), (f32, f32))> {
+ let mut it = (0..self.nodes.len()).filter(|&i| visible[i]).map(|i| self.nodes[i].pos);
+ let first = it.next()?;
+ Some(it.fold((first, first), |(lo, hi), p| ((lo.0.min(p.0), lo.1.min(p.1)), (hi.0.max(p.0), hi.1.max(p.1)))))
+ }
+}
+
+/// What the filter box asks for: every term must match. `#tag` matches a
+/// note carrying the tag (or one nested under it), `path:x` a path
+/// containing `x`, anything else a name containing it.
+#[derive(Default, Debug, PartialEq)]
+pub struct Filter {
+ tags: Vec<String>,
+ paths: Vec<String>,
+ words: Vec<String>,
+}
+
+impl Filter {
+ pub fn parse(q: &str) -> Filter {
+ let mut f = Filter::default();
+ for term in q.split_whitespace() {
+ let t = term.to_lowercase();
+ if let Some(tag) = t.strip_prefix('#').or_else(|| t.strip_prefix("tag:")) {
+ if !tag.is_empty() {
+ f.tags.push(tag.to_string());
+ }
+ } else if let Some(p) = t.strip_prefix("path:") {
+ if !p.is_empty() {
+ f.paths.push(p.to_string());
+ }
+ } else {
+ f.words.push(t);
+ }
+ }
+ f
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.tags.is_empty() && self.paths.is_empty() && self.words.is_empty()
+ }
+
+ pub fn matches(&self, n: &Node) -> bool {
+ let name = n.name.to_lowercase();
+ let path = n.path.to_lowercase();
+ self.words.iter().all(|w| name.contains(w.as_str()))
+ && self.paths.iter().all(|p| !n.ghost && path.contains(p.as_str()))
+ && self.tags.iter().all(|t| n.tags.iter().any(|nt| nt == t || nt.starts_with(&format!("{t}/"))))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+ let dir = tempfile::tempdir().unwrap();
+ for (p, t) in files {
+ let abs = dir.path().join(p);
+ std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
+ std::fs::write(abs, t).unwrap();
+ }
+ let ix = Index::open(dir.path(), false).unwrap();
+ (dir, ix)
+ }
+
+ fn sample() -> (tempfile::TempDir, Index) {
+ vault(&[
+ ("A.md", "[[B]] [[C]] [[Nowhere]] #proj\n"),
+ ("B.md", "[[A]] back again\n"),
+ ("dir/C.md", "[[D]]\n"),
+ ("D.md", "end #proj/sub\n"),
+ ("Lone.md", "no links\n"),
+ ("pic.png", "x"),
+ ])
+ }
+
+ #[test]
+ fn nodes_edges_and_ghosts() {
+ let (_d, ix) = sample();
+ let g = LinkGraph::build(&ix, None);
+ let names: Vec<&str> = g.nodes.iter().map(|n| n.name.as_str()).collect();
+ assert_eq!(names, ["A", "B", "D", "Lone", "C", "Nowhere"]);
+ assert!(g.nodes[5].ghost);
+ // A–B once although linked both ways; A–C, A–Nowhere, C–D.
+ assert_eq!(g.edges.len(), 4);
+ let a = g.index_of("A.md").unwrap();
+ assert_eq!(g.degree(a), 3);
+ assert!(g.radius(a) > g.radius(g.index_of("Lone.md").unwrap()));
+ }
+
+ #[test]
+ fn hops_reach_the_neighbourhood() {
+ let (_d, ix) = sample();
+ let g = LinkGraph::build(&ix, None);
+ let d = g.index_of("D.md").unwrap();
+ let names = |v: Vec<bool>| -> Vec<String> {
+ v.iter().enumerate().filter(|(_, &s)| s).map(|(i, _)| g.nodes[i].name.clone()).collect()
+ };
+ assert_eq!(names(g.within(d, 1)), ["D", "C"]);
+ assert_eq!(names(g.within(d, 2)), ["A", "D", "C"]);
+ }
+
+ #[test]
+ fn layout_settles_and_keeps_links_short() {
+ let (_d, ix) = sample();
+ let mut g = LinkGraph::build(&ix, None);
+ let all = vec![true; g.len()];
+ let mut steps = 0;
+ while g.step(&all) {
+ steps += 1;
+ assert!(steps < 2000, "never settled");
+ }
+ let dist = |a: &str, b: &str| {
+ let (p, q) = (g.nodes[g.index_of(a).unwrap()].pos, g.nodes[g.index_of(b).unwrap()].pos);
+ ((p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)).sqrt()
+ };
+ // Linked notes sit closer than an unlinked pair.
+ assert!(dist("A.md", "B.md") < dist("B.md", "D.md"), "{} vs {}", dist("A.md", "B.md"), dist("B.md", "D.md"));
+ assert!(g.nodes.iter().all(|n| n.pos.0.is_finite() && n.pos.1.is_finite()));
+ }
+
+ #[test]
+ fn rebuild_keeps_positions_and_pins() {
+ let (_d, ix) = sample();
+ let mut g = LinkGraph::build(&ix, None);
+ let a = g.index_of("A.md").unwrap();
+ g.nodes[a].pos = (500.0, -40.0);
+ g.nodes[a].pinned = true;
+ let g2 = LinkGraph::build(&ix, Some(&g));
+ let a2 = g2.index_of("A.md").unwrap();
+ assert_eq!(g2.nodes[a2].pos, (500.0, -40.0));
+ assert!(g2.nodes[a2].pinned);
+ }
+
+ #[test]
+ fn filters_by_name_path_and_tag() {
+ let (_d, ix) = sample();
+ let g = LinkGraph::build(&ix, None);
+ let hits = |q: &str| -> Vec<String> {
+ let f = Filter::parse(q);
+ g.nodes.iter().filter(|n| f.matches(n)).map(|n| n.name.clone()).collect()
+ };
+ assert_eq!(hits("a"), ["A"]);
+ assert_eq!(hits("path:dir/"), ["C"]);
+ assert_eq!(hits("#proj"), ["A", "D"]);
+ assert_eq!(hits("tag:proj/sub"), ["D"]);
+ assert!(Filter::parse(" ").is_empty());
+ }
+
+ #[test]
+ fn hit_finds_the_nearest_visible_node() {
+ let (_d, ix) = sample();
+ let mut g = LinkGraph::build(&ix, None);
+ for (i, n) in g.nodes.iter_mut().enumerate() {
+ n.pos = (i as f32 * 100.0, 0.0);
+ }
+ let mut vis = vec![true; g.len()];
+ assert_eq!(g.hit(&vis, 101.0, 2.0, 0.0), Some(1));
+ vis[1] = false;
+ assert_eq!(g.hit(&vis, 101.0, 2.0, 0.0), None);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 1bb57fd..60dc634 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,7 @@
use wayland_client::QueueHandle;
use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+mod linkgraph;
+mod vault;
mod wiring;
use cce_ui::widget::{Adapted, MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost, Event, Graph, GraphNode, MenuBar, GraphController, Dropdown, Label};
@@ -1679,5 +1681,12 @@ fn main() {
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let _guard = rt.enter();
+ // `--vault` is the notes vault's link graph, a separate app; anything
+ // else is the project editor.
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ if let Some(v) = vault::Args::parse(&args) {
+ vault::run(v);
+ return;
+ }
cce_ui::engine::run::<GraphApp>();
}
diff --git a/src/vault.rs b/src/vault.rs
new file mode 100644
index 0000000..1d5f3a7
--- /dev/null
+++ b/src/vault.rs
@@ -0,0 +1,923 @@
+//! `cce-graph --vault`: the notes vault as a link graph (Obsidian-on-cce
+//! milestone 4). A separate `Application` from the project editor —
+//! `main` picks one by its arguments.
+//!
+//! Global mode shows every note; local mode shows the notes within N links
+//! of the one open in cce-notes, which it learns by polling `current` on
+//! cce-notes' instance socket once a second. A click opens a note in
+//! cce-notes (over the same socket, or by launching it); dragging a note
+//! pins it, a right click unpins it. The filter box takes words (names),
+//! `#tag` and `path:folder`.
+//!
+//! The layout steps in `tick` until it cools, then the app goes idle: no
+//! frames while nothing moves.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, Instant};
+
+use cce_ui::engine::{Application, CursorIcon, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
+use cce_ui::widget::{
+ Adapted, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, TextBox, WidgetHost,
+};
+use cce_vault::{Index, VaultWatcher};
+use wayland_client::QueueHandle;
+
+use crate::linkgraph::{Filter, LinkGraph};
+
+const BAND_H: f32 = 40.0;
+const STATUS_H: f32 = 26.0;
+const CHIP_H: f32 = 24.0;
+const FILTER_W: f32 = 240.0;
+const LABEL_SIZE: f32 = 11.0;
+const MIN_ZOOM: f32 = 0.05;
+const MAX_ZOOM: f32 = 6.0;
+/// Layout steps per frame while it is warm.
+const STEPS_PER_FRAME: usize = 2;
+const POLL_EVERY: Duration = Duration::from_secs(1);
+/// A press that moves less than this is a click, not a drag.
+const CLICK_SLOP: f32 = 4.0;
+
+const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
+const DIM: [f32; 4] = cce_ui::colors::TEXT_DIM;
+
+fn accent(alpha: f32) -> [f32; 4] {
+ let mut c = cce_ui::colors::to_linear([0.66, 0.55, 0.98, 1.0]);
+ c[3] = alpha;
+ c
+}
+
+fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+ let s = cce_ui::colors::to_srgb(linear);
+ [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
+}
+
+/// `cce-graph --vault [DIR] [--local [NOTE]]`, parsed in `main`.
+pub struct Args {
+ pub vault: Option<PathBuf>,
+ pub local: bool,
+ pub note: Option<String>,
+}
+
+impl Args {
+ /// `None` when the arguments do not ask for vault mode.
+ pub fn parse(args: &[String]) -> Option<Args> {
+ let at = args.iter().position(|a| a == "--vault")?;
+ let operand = |i: usize| args.get(i).filter(|a| !a.starts_with("--")).cloned();
+ let vault = operand(at + 1).map(PathBuf::from);
+ let local_at = args.iter().position(|a| a == "--local");
+ Some(Args { vault, local: local_at.is_some(), note: local_at.and_then(|i| operand(i + 1)) })
+ }
+}
+
+static ARGS: OnceLock<Args> = OnceLock::new();
+
+// ---- single instance -------------------------------------------------------
+
+const SOCKET: &str = "cce-graph-vault";
+static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
+static OWNED: Mutex<Option<String>> = Mutex::new(None);
+
+/// The line a launch hands a running instance.
+fn launch_line(args: &Args) -> String {
+ match (args.local, &args.note) {
+ (true, Some(n)) => format!("local {n}"),
+ (true, None) => "local".into(),
+ (false, _) => "global".into(),
+ }
+}
+
+/// True when a running instance took this launch.
+fn forward_or_claim(args: &Args) -> bool {
+ let path = cce_ui::ipc::socket_path(SOCKET);
+ let forward = |path: &str| -> bool {
+ let Ok(mut s) = UnixStream::connect(path) else { return false };
+ if s.write_all(format!("{}\n", launch_line(args)).as_bytes()).is_err() {
+ return false;
+ }
+ let mut reply = String::new();
+ BufReader::new(s).read_line(&mut reply).is_ok()
+ };
+ if forward(&path) {
+ return true;
+ }
+ let _ = std::fs::remove_file(&path);
+ match UnixListener::bind(&path) {
+ Ok(l) => {
+ *CLAIMED.lock().unwrap() = Some(l);
+ *OWNED.lock().unwrap() = Some(path);
+ false
+ }
+ Err(_) => forward(&path),
+ }
+}
+
+fn spawn_listener(sender: calloop::channel::Sender<Message>) {
+ let Some(listener) = CLAIMED.lock().unwrap().take() else { return };
+ std::thread::spawn(move || {
+ for conn in listener.incoming().flatten() {
+ let mut reader = BufReader::new(conn);
+ let mut line = String::new();
+ if reader.read_line(&mut line).is_err() {
+ continue;
+ }
+ let line = line.trim();
+ let msg = match line.split_once(' ') {
+ Some(("local", note)) => Message::Mode { local: true, note: Some(note.to_string()) },
+ _ if line == "local" => Message::Mode { local: true, note: None },
+ _ if line == "global" => Message::Mode { local: false, note: None },
+ _ => continue,
+ };
+ if sender.send(msg).is_err() {
+ return;
+ }
+ let _ = reader.get_mut().write_all(b"ok\n");
+ }
+ });
+}
+
+// ---- cce-notes over its socket ---------------------------------------------
+
+fn notes_socket() -> String {
+ cce_ui::ipc::socket_path("cce-notes")
+}
+
+/// The note open in cce-notes, by vault path; `None` when it is not
+/// running or has no note open.
+fn notes_current() -> Option<String> {
+ let mut s = UnixStream::connect(notes_socket()).ok()?;
+ s.set_read_timeout(Some(Duration::from_millis(300))).ok()?;
+ s.write_all(b"current\n").ok()?;
+ let mut reply = String::new();
+ BufReader::new(s).read_line(&mut reply).ok()?;
+ reply.trim().strip_prefix("ok ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty())
+}
+
+/// Show a note in cce-notes: hand it to the running instance, or start one.
+fn open_in_notes(vault: &std::path::Path, abs: &std::path::Path) -> Result<(), String> {
+ let target = abs.to_string_lossy();
+ if let Ok(mut s) = UnixStream::connect(notes_socket()) {
+ s.write_all(format!("open {target}\n").as_bytes()).map_err(|e| e.to_string())?;
+ let mut reply = String::new();
+ let _ = BufReader::new(s).read_line(&mut reply);
+ return Ok(());
+ }
+ std::process::Command::new("cce-notes")
+ .arg("--vault")
+ .arg(vault)
+ .arg("open")
+ .arg(abs)
+ .spawn()
+ .map(|_| ())
+ .map_err(|e| format!("could not start cce-notes: {e}"))
+}
+
+// ---- the app ---------------------------------------------------------------
+
+#[derive(Debug, Clone)]
+pub enum Message {
+ VaultChanged(Vec<PathBuf>),
+ Mode { local: bool, note: Option<String> },
+ Exit,
+}
+
+enum Drag {
+ None,
+ Pan { last: (f32, f32) },
+ Node { i: usize, from: (f32, f32), moved: bool },
+}
+
+pub struct VaultApp {
+ index: Option<Index>,
+ error: Option<String>,
+ _watcher: Option<VaultWatcher>,
+ graph: LinkGraph,
+ visible: Vec<bool>,
+
+ local: bool,
+ depth: usize,
+ /// The local graph's centre, by vault path.
+ center: Option<String>,
+ next_poll: Instant,
+
+ filter_input: Adapted<TextBox>,
+ filter_seen: String,
+
+ /// World point at the middle of the graph area, and px per world unit.
+ cam: (f32, f32),
+ zoom: f32,
+ /// Fit the camera to the visible nodes once the layout has spread,
+ /// and again when it settles unless the user has moved the camera.
+ fit_pending: bool,
+ refit_on_settle: bool,
+ hover: Option<usize>,
+ drag: Drag,
+ pointer: (f32, f32),
+ status: Option<String>,
+
+ width: u32,
+ height: u32,
+ needs_rebuild: bool,
+ ui_context: cce_ui::context::UiContext,
+ widgets_registered: bool,
+}
+
+/// Where the band's controls sit.
+struct Metrics {
+ area: Rect,
+ global_chip: Rect,
+ local_chip: Rect,
+ depth_chip: Rect,
+ filter: Rect,
+}
+
+impl VaultApp {
+ fn metrics(&self) -> Metrics {
+ let (w, h) = (self.width as f32, self.height as f32);
+ let inset = cce_ui::layout::root_plate_inset();
+ let y = (BAND_H - CHIP_H) / 2.0;
+ let filter_w = FILTER_W.min(w * 0.35);
+ let filter = Rect { x: w - inset - filter_w, y: y - 2.0, width: filter_w, height: CHIP_H + 4.0 };
+ let depth_chip = Rect { x: filter.x - 12.0 - 76.0, y, width: 76.0, height: CHIP_H };
+ let local_chip = Rect { x: depth_chip.x - 8.0 - 64.0, y, width: 64.0, height: CHIP_H };
+ let global_chip = Rect { x: local_chip.x - 64.0, y, width: 64.0, height: CHIP_H };
+ Metrics {
+ area: Rect { x: 0.0, y: BAND_H, width: w, height: (h - BAND_H - STATUS_H).max(0.0) },
+ global_chip,
+ local_chip,
+ depth_chip,
+ filter,
+ }
+ }
+
+ fn filter_query(&self) -> String {
+ if self.filter_input.editing {
+ self.filter_input.edit_buffer.clone()
+ } else {
+ self.filter_input.text.clone()
+ }
+ }
+
+ /// Which nodes show: the local neighbourhood (if local), narrowed by
+ /// the filter.
+ fn recompute_visible(&mut self) {
+ let n = self.graph.len();
+ let mut vis = vec![true; n];
+ if self.local {
+ match self.center.as_deref().and_then(|c| self.graph.index_of(c)) {
+ Some(c) => vis = self.graph.within(c, self.depth),
+ None => vis = vec![false; n],
+ }
+ }
+ let f = Filter::parse(&self.filter_query());
+ if !f.is_empty() {
+ for (i, v) in vis.iter_mut().enumerate() {
+ *v = *v && f.matches(&self.graph.nodes[i]);
+ }
+ }
+ self.visible = vis;
+ self.hover = None;
+ self.graph.reheat(0.4);
+ self.needs_rebuild = true;
+ }
+
+ fn set_mode(&mut self, local: bool, note: Option<String>) {
+ if let Some(n) = note {
+ self.center = self.index.as_ref().and_then(|ix| ix.lookup(&n)).or(Some(n));
+ }
+ if local && self.center.is_none() {
+ self.center = notes_current();
+ }
+ self.local = local;
+ self.recompute_visible();
+ self.fit_pending = true;
+ self.refit_on_settle = true;
+ }
+
+ fn poll_center(&mut self) {
+ if !self.local {
+ return;
+ }
+ let now = Instant::now();
+ if now < self.next_poll {
+ return;
+ }
+ self.next_poll = now + POLL_EVERY;
+ if let Some(cur) = notes_current() {
+ if self.center.as_deref() != Some(cur.as_str()) {
+ self.center = Some(cur);
+ self.recompute_visible();
+ self.fit_pending = true;
+ }
+ }
+ }
+
+ fn vault_changed(&mut self, paths: Vec<PathBuf>) {
+ let Some(ix) = self.index.as_mut() else { return };
+ ix.apply_changes(&paths);
+ let g = LinkGraph::build(ix, Some(&self.graph));
+ self.graph = g;
+ // A rebuild keeps positions; it only needs a nudge, not a restart.
+ self.graph.alpha = 0.3;
+ self.recompute_visible();
+ }
+
+ fn to_screen(&self, area: Rect, p: (f32, f32)) -> (f32, f32) {
+ let (mx, my) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
+ (mx + (p.0 - self.cam.0) * self.zoom, my + (p.1 - self.cam.1) * self.zoom)
+ }
+
+ fn to_world(&self, area: Rect, s: (f32, f32)) -> (f32, f32) {
+ let (mx, my) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
+ (self.cam.0 + (s.0 - mx) / self.zoom, self.cam.1 + (s.1 - my) / self.zoom)
+ }
+
+ fn fit(&mut self) {
+ let area = self.metrics().area;
+ let Some((lo, hi)) = self.graph.bounds(&self.visible) else { return };
+ let (bw, bh) = ((hi.0 - lo.0).max(1.0), (hi.1 - lo.1).max(1.0));
+ let z = ((area.width - 80.0) / bw).min((area.height - 80.0) / bh);
+ self.zoom = z.clamp(MIN_ZOOM, 2.0);
+ self.cam = ((lo.0 + hi.0) / 2.0, (lo.1 + hi.1) / 2.0);
+ self.needs_rebuild = true;
+ }
+
+ fn zoom_at(&mut self, s: (f32, f32), factor: f32) {
+ let area = self.metrics().area;
+ let before = self.to_world(area, s);
+ self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
+ let after = self.to_world(area, s);
+ self.cam.0 += before.0 - after.0;
+ self.cam.1 += before.1 - after.1;
+ self.refit_on_settle = false;
+ self.needs_rebuild = true;
+ }
+
+ fn node_at(&self, s: (f32, f32)) -> Option<usize> {
+ let area = self.metrics().area;
+ if !contains(area, s.0, s.1) {
+ return None;
+ }
+ let w = self.to_world(area, s);
+ self.graph.hit(&self.visible, w.0, w.1, 4.0 / self.zoom)
+ }
+
+ fn open(&mut self, i: usize) {
+ let node = &self.graph.nodes[i];
+ if node.ghost {
+ self.status = Some(format!("“{}” does not exist yet — follow a link to it in cce-notes to create it", node.name));
+ return;
+ }
+ let Some(ix) = &self.index else { return };
+ if let Err(e) = open_in_notes(ix.root(), &ix.abs(&node.path)) {
+ self.status = Some(e);
+ }
+ }
+
+ fn paint_band(&self, pc: &mut PaintCtx, m: &Metrics) {
+ let w = self.width as f32;
+ pc.recess_edges(
+ Rect { x: 0.0, y: 0.0, width: w, height: BAND_H },
+ (0.0, 0.0, 0.0, 0.0),
+ cce_ui::layout::bar_wall_width(),
+ (false, false, true, false),
+ );
+ let (family, size) = cce_ui::layout::menubar_font_parsed();
+ let inset = cce_ui::layout::root_plate_inset();
+ let title = match (&self.index, self.local, &self.center) {
+ (Some(_), true, Some(c)) => format!("Local graph · {}", c.trim_end_matches(".md")),
+ (Some(_), true, None) => "Local graph".to_string(),
+ (Some(ix), false, _) => format!("Graph · {}", vault_name(ix.root())),
+ (None, _, _) => "Graph".to_string(),
+ };
+ let ty = cce_ui::layout::align_text_y(0.0, BAND_H, size, 0.0);
+ pc.text_with(title, inset, ty, size, srgb_u8(FG), Some(family.clone()), Some([inset, 0.0, m.global_chip.x - 12.0, BAND_H]));
+ let chip = |pc: &mut PaintCtx, r: Rect, label: &str, on: bool| {
+ pc.rounded_rect(r, 6.0, (true, true, true, true), if on { accent(0.25) } else { [1.0, 1.0, 1.0, 0.06] });
+ let lw = label.chars().count() as f32 * size * 0.6;
+ pc.text_with(
+ label.to_string(),
+ r.x + ((r.width - lw) / 2.0).max(2.0),
+ cce_ui::layout::align_text_y(r.y, r.height, size, 0.0),
+ size,
+ srgb_u8(if on { FG } else { DIM }),
+ Some(family.clone()),
+ Some([r.x, r.y, r.x + r.width, r.y + r.height]),
+ );
+ };
+ chip(pc, m.global_chip, "Global", !self.local);
+ chip(pc, m.local_chip, "Local", self.local);
+ if self.local {
+ chip(pc, m.depth_chip, &format!("Depth {}", self.depth), false);
+ }
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.filter_input, pc);
+ }
+
+ fn paint_status(&self, pc: &mut PaintCtx) {
+ let (w, h) = (self.width as f32, self.height as f32);
+ let y = h - STATUS_H;
+ pc.recess_edges(
+ Rect { x: 0.0, y, width: w, height: STATUS_H },
+ (0.0, 0.0, 0.0, 0.0),
+ cce_ui::layout::bar_wall_width(),
+ (true, false, false, false),
+ );
+ let (family, size) = cce_ui::layout::statusbar_font_parsed();
+ let inset = cce_ui::layout::root_plate_inset();
+ let ty = cce_ui::layout::align_text_y(y, STATUS_H, size, 0.0);
+ let msg = if let Some(s) = &self.status {
+ s.clone()
+ } else if self.index.is_none() {
+ String::new()
+ } else if self.local && self.center.is_none() {
+ "Open a note in cce-notes; the local graph follows it".to_string()
+ } else {
+ let notes = self.visible.iter().filter(|v| **v).count();
+ let links = self.graph.edges.iter().filter(|(a, b)| self.visible[*a] && self.visible[*b]).count();
+ format!("{notes} notes · {links} links · drag to pin, right-click to unpin, Ctrl+0 to fit")
+ };
+ pc.text_with(msg, inset, ty, size, srgb_u8(DIM), Some(family), Some([inset, y, w - inset, h]));
+ }
+
+ fn paint_graph(&self, pc: &mut PaintCtx, area: Rect) {
+ if let Some(e) = &self.error {
+ let (family, size) = cce_ui::layout::list_font_parsed();
+ for (i, line) in e.lines().enumerate() {
+ pc.text_with(line.to_string(), area.x + 28.0, area.y + 28.0 + i as f32 * size * 1.6, size, srgb_u8(if i == 0 { FG } else { DIM }), Some(family.clone()), None);
+ }
+ return;
+ }
+ let g = &self.graph;
+ let vis = &self.visible;
+ let center = if self.local { self.center.as_deref().and_then(|c| g.index_of(c)) } else { None };
+ // While a node is hovered, it and its neighbours stand out and
+ // everything else steps back.
+ let mut lit = vec![false; g.len()];
+ if let Some(h) = self.hover {
+ lit[h] = true;
+ for &j in g.neighbors(h) {
+ lit[j] = true;
+ }
+ }
+ let focus = self.hover.is_some();
+ let margin = 40.0;
+ let on_screen = |p: (f32, f32)| {
+ p.0 > area.x - margin && p.0 < area.x + area.width + margin && p.1 > area.y - margin && p.1 < area.y + area.height + margin
+ };
+ let line_w = (self.zoom * 1.2).clamp(0.6, 2.0);
+ pc.clip(area, |pc| {
+ for &(a, b) in &g.edges {
+ if !(vis[a] && vis[b]) {
+ continue;
+ }
+ let (pa, pb) = (self.to_screen(area, g.nodes[a].pos), self.to_screen(area, g.nodes[b].pos));
+ if !on_screen(pa) && !on_screen(pb) {
+ continue;
+ }
+ let touches = self.hover.is_some_and(|h| h == a || h == b);
+ let color = if touches {
+ accent(0.9)
+ } else if focus {
+ [1.0, 1.0, 1.0, 0.04]
+ } else {
+ [1.0, 1.0, 1.0, 0.16]
+ };
+ pc.vector(pa.0, pa.1, pb.0, pb.1, if touches { line_w * 1.5 } else { line_w }, color, Cap::Round);
+ }
+ // Labels fade in from this zoom, so a whole-vault view is
+ // dots and lines and names appear as you zoom in.
+ let labels_from = 1.1;
+ for i in (0..g.len()).filter(|&i| vis[i]) {
+ let p = self.to_screen(area, g.nodes[i].pos);
+ if !on_screen(p) {
+ continue;
+ }
+ let r = (g.radius(i) * self.zoom).max(1.5);
+ let node = &g.nodes[i];
+ let color = if Some(i) == self.hover || Some(i) == center {
+ accent(1.0)
+ } else if node.ghost {
+ [0.25, 0.25, 0.28, if focus && !lit[i] { 0.3 } else { 1.0 }]
+ } else if focus && !lit[i] {
+ [0.45, 0.45, 0.5, 0.25]
+ } else {
+ [0.62, 0.62, 0.68, 1.0]
+ };
+ pc.circle(p.0, p.1, r, color);
+ if node.pinned {
+ pc.circle(p.0, p.1, (r * 0.35).max(1.0), [0.1, 0.1, 0.12, 1.0]);
+ }
+ // Labels fade in with zoom; the hovered node and its
+ // neighbours always show theirs.
+ let alpha = if lit[i] || Some(i) == center {
+ 1.0
+ } else if focus {
+ 0.0
+ } else {
+ ((self.zoom - labels_from) / 0.5).clamp(0.0, 1.0)
+ };
+ if alpha <= 0.02 {
+ continue;
+ }
+ let lw = node.name.chars().count() as f32 * LABEL_SIZE * 0.55;
+ let color = if node.ghost { DIM } else { FG };
+ pc.text_faded(
+ node.name.clone(),
+ p.0 - lw / 2.0,
+ p.1 + r + 3.0,
+ LABEL_SIZE,
+ srgb_u8(color),
+ alpha,
+ Some("sans-serif".to_string()),
+ Some([area.x, area.y, area.x + area.width, area.y + area.height]),
+ );
+ }
+ });
+ }
+}
+
+fn contains(r: Rect, x: f32, y: f32) -> bool {
+ x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
+}
+
+fn vault_name(root: &std::path::Path) -> String {
+ root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
+}
+
+impl Application for VaultApp {
+ type Message = Message;
+
+ fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+ Some(&self.ui_context)
+ }
+
+ fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
+ Some(&mut self.ui_context)
+ }
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Message>) -> Self {
+ let args = ARGS.get().expect("args set in run");
+ let root = cce_vault::config::vault_root(args.vault.as_deref()).map_err(|e| {
+ format!("No vault: {e}\nSet `vault {{ path \"~/Notes\" }}` in ~/.config/cce/config.kdl, or pass --vault <dir>.")
+ });
+ let (index, error, watcher) = match root {
+ Ok(root) => match Index::open(&root, true) {
+ Ok(ix) => {
+ let tx = sender.clone();
+ let w = VaultWatcher::spawn(&root, move |paths| {
+ let _ = tx.send(Message::VaultChanged(paths));
+ })
+ .ok();
+ (Some(ix), None, w)
+ }
+ Err(e) => (None, Some(format!("Could not read the vault at {}\n{e}", root.display())), None),
+ },
+ Err(e) => (None, Some(e), None),
+ };
+ spawn_listener(sender.clone());
+ let graph = index.as_ref().map(|ix| LinkGraph::build(ix, None)).unwrap_or_default();
+ let n = graph.len();
+ let mut app = VaultApp {
+ index,
+ error,
+ _watcher: watcher,
+ graph,
+ visible: vec![true; n],
+ local: false,
+ depth: 1,
+ center: None,
+ next_poll: Instant::now(),
+ filter_input: TextBox::new(String::new()).with_placeholder("Filter: words, #tag, path:"),
+ filter_seen: String::new(),
+ cam: (0.0, 0.0),
+ zoom: 1.0,
+ fit_pending: true,
+ refit_on_settle: true,
+ hover: None,
+ drag: Drag::None,
+ pointer: (0.0, 0.0),
+ status: None,
+ width: 1000,
+ height: 700,
+ needs_rebuild: true,
+ ui_context: cce_ui::context::UiContext::new(),
+ widgets_registered: false,
+ };
+ app.set_mode(args.local, args.note.clone());
+ app
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "Graph".to_string(),
+ app_id: "cce-graph-vault".to_string(),
+ width: 1000,
+ height: 700,
+ fullscreen: false,
+ min_size: Some((420, 300)),
+ }
+ }
+
+ fn update(&mut self, msg: Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+ match msg {
+ Message::VaultChanged(paths) => self.vault_changed(paths),
+ Message::Mode { local, note } => self.set_mode(local, note),
+ Message::Exit => *_exit = true,
+ }
+ *needs_rebuild = true;
+ }
+
+ fn idle_poll_interval(&self) -> Option<Duration> {
+ // The local graph follows cce-notes; nothing else needs waking.
+ self.local.then_some(POLL_EVERY)
+ }
+
+ fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+ if self.ui_context.tick(dt) {
+ *needs_rebuild = true;
+ }
+ self.poll_center();
+ if !self.graph.settled() {
+ for _ in 0..STEPS_PER_FRAME {
+ self.graph.step(&self.visible);
+ }
+ if self.fit_pending && self.graph.alpha < 0.35 {
+ self.fit_pending = false;
+ self.fit();
+ }
+ *needs_rebuild = true;
+ } else if self.fit_pending || self.refit_on_settle {
+ self.fit_pending = false;
+ self.refit_on_settle = false;
+ self.fit();
+ *needs_rebuild = true;
+ }
+ if self.needs_rebuild {
+ *needs_rebuild = true;
+ }
+ }
+
+ fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+ if !self.widgets_registered {
+ self.widgets_registered = true;
+ let (id, ptr) = (self.filter_input.id(), self.filter_input.as_ptr_mut());
+ self.ui_context.register_widget(id, ptr);
+ }
+ let resized = self.width != size.width as u32 || self.height != size.height as u32;
+ self.width = size.width as u32;
+ self.height = size.height as u32;
+ cce_ui::scale::set_scale_factor(scale as f32);
+ let m = self.metrics();
+ if self.needs_rebuild || resized {
+ let f = m.filter;
+ self.filter_input.set_rect(f.x, f.y, f.width, f.height);
+ self.ui_context.rebuild_spatial_grid();
+ self.needs_rebuild = false;
+ }
+ let mut pc = PaintCtx::new();
+ pc.root_plate(size.width as f32, size.height as f32);
+ self.paint_graph(&mut pc, m.area);
+ self.paint_band(&mut pc, &m);
+ self.paint_status(&mut pc);
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
+ let m = self.metrics();
+ if contains(m.filter, x, y) {
+ return Some(CursorIcon::Text);
+ }
+ match self.drag {
+ Drag::Pan { .. } => Some(CursorIcon::Grabbing),
+ Drag::Node { .. } => Some(CursorIcon::Grabbing),
+ Drag::None if self.hover.is_some() => Some(CursorIcon::Pointer),
+ Drag::None => None,
+ }
+ }
+
+ fn on_exit(&mut self) {
+ if let Some(ix) = self.index.as_mut() {
+ let _ = ix.save_cache();
+ }
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let s = (pos.x as f32, pos.y as f32);
+ self.pointer = s;
+ let area = self.metrics().area;
+ match &mut self.drag {
+ Drag::Pan { last } => {
+ let (dx, dy) = (s.0 - last.0, s.1 - last.1);
+ *last = s;
+ self.cam.0 -= dx / self.zoom;
+ self.cam.1 -= dy / self.zoom;
+ self.refit_on_settle = false;
+ *needs_rebuild = true;
+ return;
+ }
+ Drag::Node { i, from, moved } => {
+ let i = *i;
+ if !*moved && ((s.0 - from.0).abs() > CLICK_SLOP || (s.1 - from.1).abs() > CLICK_SLOP) {
+ *moved = true;
+ }
+ if *moved {
+ let w = self.to_world(area, s);
+ let node = &mut self.graph.nodes[i];
+ node.pos = w;
+ node.pinned = true;
+ self.graph.reheat(0.25);
+ *needs_rebuild = true;
+ }
+ return;
+ }
+ Drag::None => {}
+ }
+ let h = self.node_at(s);
+ if h != self.hover {
+ self.hover = h;
+ *needs_rebuild = true;
+ }
+ let ev = Event::PointerMove { x: s.0, y: s.1, local_x: s.0, local_y: s.1 };
+ if self.ui_context.propagate_event(&ev, self.filter_input.id()) {
+ *needs_rebuild = true;
+ }
+ }
+
+ fn handle_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ pos: LogicalPosition,
+ needs_rebuild: &mut bool,
+ ) -> Option<Message> {
+ let s = (pos.x as f32, pos.y as f32);
+ *needs_rebuild = true;
+ let m = self.metrics();
+ let ev = Event::MouseButton { button, state, x: s.0, y: s.1, local_x: s.0, local_y: s.1 };
+ let pressed = state == ElementState::Pressed;
+
+ if contains(m.filter, s.0, s.1) {
+ if pressed && !self.filter_input.editing {
+ self.ui_context.set_focused(&mut self.filter_input);
+ WidgetHost::focus(&mut self.filter_input);
+ }
+ self.ui_context.propagate_event(&ev, self.filter_input.id());
+ return None;
+ }
+ if pressed && self.filter_input.editing {
+ self.filter_input.unfocus();
+ }
+ if pressed && button == MouseButton::Left {
+ if contains(m.global_chip, s.0, s.1) {
+ self.set_mode(false, None);
+ return None;
+ }
+ if contains(m.local_chip, s.0, s.1) {
+ self.set_mode(true, None);
+ return None;
+ }
+ if self.local && contains(m.depth_chip, s.0, s.1) {
+ self.depth = self.depth % 4 + 1;
+ self.recompute_visible();
+ self.fit_pending = true;
+ return None;
+ }
+ }
+ if !contains(m.area, s.0, s.1) && pressed {
+ return None;
+ }
+ match (button, state) {
+ (MouseButton::Left, ElementState::Pressed) => {
+ self.drag = match self.node_at(s) {
+ Some(i) => Drag::Node { i, from: s, moved: false },
+ None => Drag::Pan { last: s },
+ };
+ self.status = None;
+ }
+ (MouseButton::Left, ElementState::Released) => {
+ if let Drag::Node { i, moved: false, .. } = self.drag {
+ self.open(i);
+ }
+ self.drag = Drag::None;
+ self.hover = self.node_at(s);
+ }
+ (MouseButton::Right, ElementState::Pressed) => {
+ if let Some(i) = self.node_at(s) {
+ self.graph.nodes[i].pinned = false;
+ self.graph.reheat(0.3);
+ }
+ }
+ _ => {}
+ }
+ None
+ }
+
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let s = (pos.x as f32, pos.y as f32);
+ if !contains(self.metrics().area, s.0, s.1) {
+ return;
+ }
+ match delta {
+ // A wheel notch zooms about the pointer, as Obsidian's does.
+ MouseScrollDelta::LineDelta(_, y) => self.zoom_at(s, 1.15f32.powf(*y)),
+ // A trackpad pans; with Ctrl (a pinch on most stacks) it zooms.
+ MouseScrollDelta::PixelDelta(p) => {
+ if self.ui_context.ctrl_pressed {
+ self.zoom_at(s, 1.01f32.powf(p.y as f32));
+ } else {
+ self.cam.0 -= p.x as f32 / self.zoom;
+ self.cam.1 -= p.y as f32 / self.zoom;
+ self.refit_on_settle = false;
+ }
+ }
+ }
+ *needs_rebuild = true;
+ }
+
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
+ *needs_rebuild = true;
+ let pressed = event.state == ElementState::Pressed;
+ if self.filter_input.editing {
+ if pressed && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
+ self.filter_input.unfocus();
+ return None;
+ }
+ let ev = Event::KeyInput(event.clone());
+ self.ui_context.propagate_event(&ev, self.filter_input.id());
+ let q = self.filter_query();
+ if q != self.filter_seen {
+ self.filter_seen = q;
+ self.recompute_visible();
+ }
+ return None;
+ }
+ if !pressed {
+ return None;
+ }
+ let k = |name: &str, default: &str| cce_ui::widget::match_key_shortcut(event, &cce_ui::input::app_chord(name, default));
+ if k("toggle_local", "ctrl+l") {
+ let local = !self.local;
+ self.set_mode(local, None);
+ } else if k("fit", "ctrl+0") {
+ self.fit();
+ } else if k("filter", "ctrl+f") {
+ self.ui_context.set_focused(&mut self.filter_input);
+ WidgetHost::focus(&mut self.filter_input);
+ } else if k("depth_more", "ctrl+=") || k("depth_more_plus", "ctrl+shift+=") {
+ self.depth = (self.depth + 1).min(4);
+ self.recompute_visible();
+ self.fit_pending = true;
+ } else if k("depth_less", "ctrl+-") {
+ self.depth = self.depth.saturating_sub(1).max(1);
+ self.recompute_visible();
+ self.fit_pending = true;
+ } else if k("quit", "ctrl+q") {
+ return Some(Message::Exit);
+ }
+ None
+ }
+}
+
+/// Run the vault graph, or hand the launch to the one already running.
+pub fn run(args: Args) {
+ if forward_or_claim(&args) {
+ return;
+ }
+ let _ = ARGS.set(args);
+ cce_ui::engine::run::<VaultApp>();
+ if let Some(p) = OWNED.lock().unwrap().take() {
+ let _ = std::fs::remove_file(p);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args(a: &[&str]) -> Vec<String> {
+ a.iter().map(|s| s.to_string()).collect()
+ }
+
+ #[test]
+ fn vault_arguments() {
+ assert!(Args::parse(&args(&[])).is_none());
+ assert!(Args::parse(&args(&["project/dir"])).is_none());
+ let a = Args::parse(&args(&["--vault"])).unwrap();
+ assert!(a.vault.is_none() && !a.local);
+ let a = Args::parse(&args(&["--vault", "/v", "--local", "Note"])).unwrap();
+ assert_eq!(a.vault.as_deref(), Some(std::path::Path::new("/v")));
+ assert!(a.local);
+ assert_eq!(a.note.as_deref(), Some("Note"));
+ let a = Args::parse(&args(&["--vault", "--local"])).unwrap();
+ assert!(a.vault.is_none() && a.local && a.note.is_none());
+ assert_eq!(launch_line(&a), "local");
+ }
+}