git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

src/vault.rs (32.7K)

  1 //! `cce-graph --vault`: the notes vault as a link graph (Obsidian-on-cce
  2 //! milestone 4). A separate `Application` from the project editor —
  3 //! `main` picks one by its arguments.
  4 //!
  5 //! Global mode shows every note; local mode shows the notes within N links
  6 //! of the one open in cce-notes, which it learns by polling `current` on
  7 //! cce-notes' instance socket once a second. A click opens a note in
  8 //! cce-notes (over the same socket, or by launching it); dragging a note
  9 //! pins it, a right click unpins it. The filter box takes words (names),
 10 //! `#tag` and `path:folder`.
 11 //!
 12 //! The layout steps in `tick` until it cools, then the app goes idle: no
 13 //! frames while nothing moves.
 14 
 15 use std::io::{BufRead, BufReader, Write};
 16 use std::os::unix::net::UnixStream;
 17 use std::path::PathBuf;
 18 use std::sync::OnceLock;
 19 use std::time::{Duration, Instant};
 20 
 21 use cce_ui::widget::Handle;
 22 use cce_ui::engine::{Application, CursorIcon, LogicalPosition, LogicalSize, WindowSettings};
 23 use cce_ui::scene::layout::Rect;
 24 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
 25 use cce_ui::widget::{
 26     Adapted, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, TextBox, WidgetHost,
 27 };
 28 use cce_vault::{Index, VaultWatcher};
 29 
 30 use crate::linkgraph::{Filter, LinkGraph};
 31 
 32 const BAND_H: f32 = 40.0;
 33 const STATUS_H: f32 = 26.0;
 34 const FILTER_W: f32 = 240.0;
 35 const LABEL_SIZE: f32 = 11.0;
 36 const MIN_ZOOM: f32 = 0.05;
 37 const MAX_ZOOM: f32 = 6.0;
 38 /// Layout steps per frame while it is warm.
 39 const STEPS_PER_FRAME: usize = 2;
 40 const POLL_EVERY: Duration = Duration::from_secs(1);
 41 /// A press that moves less than this is a click, not a drag.
 42 const CLICK_SLOP: f32 = 4.0;
 43 
 44 const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
 45 const DIM: [f32; 4] = cce_ui::colors::TEXT_DIM;
 46 
 47 fn accent(alpha: f32) -> [f32; 4] {
 48     let mut c = cce_ui::colors::to_linear([0.66, 0.55, 0.98, 1.0]);
 49     c[3] = alpha;
 50     c
 51 }
 52 
 53 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
 54     let s = cce_ui::colors::to_srgb(linear);
 55     [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
 56 }
 57 
 58 /// `cce-graph --vault [DIR] [--local [NOTE]]`, parsed in `main`.
 59 pub struct Args {
 60     pub vault: Option<PathBuf>,
 61     pub local: bool,
 62     pub note: Option<String>,
 63 }
 64 
 65 impl Args {
 66     /// `None` when the arguments do not ask for vault mode.
 67     pub fn parse(args: &[String]) -> Option<Args> {
 68         let at = args.iter().position(|a| a == "--vault")?;
 69         let operand = |i: usize| args.get(i).filter(|a| !a.starts_with("--")).cloned();
 70         let vault = operand(at + 1).map(PathBuf::from);
 71         let local_at = args.iter().position(|a| a == "--local");
 72         Some(Args { vault, local: local_at.is_some(), note: local_at.and_then(|i| operand(i + 1)) })
 73     }
 74 }
 75 
 76 static ARGS: OnceLock<Args> = OnceLock::new();
 77 
 78 // ---- single instance -------------------------------------------------------
 79 //
 80 // The claim, the race it closes and the bounded listener reads are
 81 // `cce_ui::ipc::instance`'s; the lines below are this mode's protocol.
 82 
 83 const SOCKET: &str = "cce-graph-vault";
 84 
 85 /// The line a launch hands a running instance.
 86 fn launch_line(args: &Args) -> String {
 87     match (args.local, &args.note) {
 88         (true, Some(n)) => format!("local {n}"),
 89         (true, None) => "local".into(),
 90         (false, _) => "global".into(),
 91     }
 92 }
 93 
 94 /// True when a running instance took this launch.
 95 fn forward_or_claim(args: &Args) -> bool {
 96     cce_ui::ipc::instance::forward_or_claim(SOCKET, &launch_line(args))
 97 }
 98 
 99 fn spawn_listener(sender: calloop::channel::Sender<Message>) {
100     cce_ui::ipc::instance::serve(move |line| {
101         let msg = match line.split_once(' ') {
102             Some(("local", note)) => Message::Mode { local: true, note: Some(note.to_string()) },
103             _ if line == "local" => Message::Mode { local: true, note: None },
104             _ if line == "global" => Message::Mode { local: false, note: None },
105             _ => return None,
106         };
107         sender.send(msg).ok()?;
108         Some("ok".into())
109     });
110 }
111 
112 // ---- cce-notes over its socket ---------------------------------------------
113 
114 fn notes_socket() -> String {
115     cce_ui::ipc::socket_path("cce-notes")
116 }
117 
118 /// The note open in cce-notes, by vault path; `None` when it is not
119 /// running or has no note open.
120 fn notes_current() -> Option<String> {
121     let mut s = UnixStream::connect(notes_socket()).ok()?;
122     s.set_read_timeout(Some(Duration::from_millis(300))).ok()?;
123     s.write_all(b"current\n").ok()?;
124     let mut reply = String::new();
125     BufReader::new(s).read_line(&mut reply).ok()?;
126     reply.trim().strip_prefix("ok ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty())
127 }
128 
129 /// Show a note in cce-notes: hand it to the running instance, or start one.
130 fn open_in_notes(vault: &std::path::Path, abs: &std::path::Path) -> Result<(), String> {
131     let target = abs.to_string_lossy();
132     if let Ok(mut s) = UnixStream::connect(notes_socket()) {
133         s.write_all(format!("open {target}\n").as_bytes()).map_err(|e| e.to_string())?;
134         let mut reply = String::new();
135         let _ = BufReader::new(s).read_line(&mut reply);
136         return Ok(());
137     }
138     std::process::Command::new("cce-notes")
139         .arg("--vault")
140         .arg(vault)
141         .arg("open")
142         .arg(abs)
143         .spawn()
144         .map(|_| ())
145         .map_err(|e| format!("could not start cce-notes: {e}"))
146 }
147 
148 // ---- the app ---------------------------------------------------------------
149 
150 #[derive(Debug, Clone)]
151 pub enum Message {
152     VaultChanged(Vec<PathBuf>),
153     Mode { local: bool, note: Option<String> },
154     Exit,
155 }
156 
157 enum Drag {
158     None,
159     Pan { last: (f32, f32) },
160     Node { i: usize, from: (f32, f32), moved: bool },
161 }
162 
163 pub struct VaultApp {
164     index: Option<Index>,
165     error: Option<String>,
166     _watcher: Option<VaultWatcher>,
167     graph: LinkGraph,
168     visible: Vec<bool>,
169 
170     local: bool,
171     depth: usize,
172     /// The local graph's centre, by vault path.
173     center: Option<String>,
174     next_poll: Instant,
175 
176     filter_input: Handle<Adapted<TextBox>>,
177     filter_seen: String,
178 
179     /// World point at the middle of the graph area, and px per world unit.
180     cam: (f32, f32),
181     zoom: f32,
182     /// Fit the camera to the visible nodes once the layout has spread,
183     /// and again when it settles unless the user has moved the camera.
184     fit_pending: bool,
185     refit_on_settle: bool,
186     hover: Option<usize>,
187     drag: Drag,
188     pointer: (f32, f32),
189     status: Option<String>,
190 
191     width: u32,
192     height: u32,
193     needs_rebuild: bool,
194     ui_context: cce_ui::context::UiContext,
195     widgets_registered: bool,
196 }
197 
198 /// Where the band's controls sit.
199 struct Metrics {
200     area: Rect,
201     global_chip: Rect,
202     local_chip: Rect,
203     depth_chip: Rect,
204     filter: Rect,
205 }
206 
207 impl VaultApp {
208     fn metrics(&self) -> Metrics {
209         let (w, h) = (self.width as f32, self.height as f32);
210         let inset = cce_ui::layout::root_plate_inset();
211         // The chips and filter box at the toolkit's control heights, each
212         // centred in the band.
213         let chip_h = cce_ui::layout::button_height();
214         let filter_h = cce_ui::layout::textbox_height();
215         let y = (BAND_H - chip_h) / 2.0;
216         let filter_w = FILTER_W.min(w * 0.35);
217         let filter = Rect { x: w - inset - filter_w, y: (BAND_H - filter_h) / 2.0, width: filter_w, height: filter_h };
218         let depth_chip = Rect { x: filter.x - 12.0 - 76.0, y, width: 76.0, height: chip_h };
219         let local_chip = Rect { x: depth_chip.x - 8.0 - 64.0, y, width: 64.0, height: chip_h };
220         let global_chip = Rect { x: local_chip.x - 64.0, y, width: 64.0, height: chip_h };
221         Metrics {
222             area: Rect { x: 0.0, y: BAND_H, width: w, height: (h - BAND_H - STATUS_H).max(0.0) },
223             global_chip,
224             local_chip,
225             depth_chip,
226             filter,
227         }
228     }
229 
230     fn filter_query(&self) -> String {
231         if self.ui_context[self.filter_input].editing {
232             self.ui_context[self.filter_input].edit_buffer.clone()
233         } else {
234             self.ui_context[self.filter_input].text.clone()
235         }
236     }
237 
238     /// Which nodes show: the local neighbourhood (if local), narrowed by
239     /// the filter.
240     fn recompute_visible(&mut self) {
241         let n = self.graph.len();
242         let mut vis = vec![true; n];
243         if self.local {
244             match self.center.as_deref().and_then(|c| self.graph.index_of(c)) {
245                 Some(c) => vis = self.graph.within(c, self.depth),
246                 None => vis = vec![false; n],
247             }
248         }
249         let f = Filter::parse(&self.filter_query());
250         if !f.is_empty() {
251             for (i, v) in vis.iter_mut().enumerate() {
252                 *v = *v && f.matches(&self.graph.nodes[i]);
253             }
254         }
255         self.visible = vis;
256         self.hover = None;
257         self.graph.reheat(0.4);
258         self.needs_rebuild = true;
259     }
260 
261     fn set_mode(&mut self, local: bool, note: Option<String>) {
262         if let Some(n) = note {
263             self.center = self.index.as_ref().and_then(|ix| ix.lookup(&n)).or(Some(n));
264         }
265         if local && self.center.is_none() {
266             self.center = notes_current();
267         }
268         self.local = local;
269         self.recompute_visible();
270         self.fit_pending = true;
271         self.refit_on_settle = true;
272     }
273 
274     fn poll_center(&mut self) {
275         if !self.local {
276             return;
277         }
278         let now = Instant::now();
279         if now < self.next_poll {
280             return;
281         }
282         self.next_poll = now + POLL_EVERY;
283         if let Some(cur) = notes_current() {
284             if self.center.as_deref() != Some(cur.as_str()) {
285                 self.center = Some(cur);
286                 self.recompute_visible();
287                 self.fit_pending = true;
288             }
289         }
290     }
291 
292     fn vault_changed(&mut self, paths: Vec<PathBuf>) {
293         let Some(ix) = self.index.as_mut() else { return };
294         ix.apply_changes(&paths);
295         let g = LinkGraph::build(ix, Some(&self.graph));
296         self.graph = g;
297         // A rebuild keeps positions; it only needs a nudge, not a restart.
298         self.graph.alpha = 0.3;
299         self.recompute_visible();
300     }
301 
302     fn to_screen(&self, area: Rect, p: (f32, f32)) -> (f32, f32) {
303         let (mx, my) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
304         (mx + (p.0 - self.cam.0) * self.zoom, my + (p.1 - self.cam.1) * self.zoom)
305     }
306 
307     fn to_world(&self, area: Rect, s: (f32, f32)) -> (f32, f32) {
308         let (mx, my) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
309         (self.cam.0 + (s.0 - mx) / self.zoom, self.cam.1 + (s.1 - my) / self.zoom)
310     }
311 
312     fn fit(&mut self) {
313         let area = self.metrics().area;
314         let Some((lo, hi)) = self.graph.bounds(&self.visible) else { return };
315         let (bw, bh) = ((hi.0 - lo.0).max(1.0), (hi.1 - lo.1).max(1.0));
316         let z = ((area.width - 80.0) / bw).min((area.height - 80.0) / bh);
317         self.zoom = z.clamp(MIN_ZOOM, 2.0);
318         self.cam = ((lo.0 + hi.0) / 2.0, (lo.1 + hi.1) / 2.0);
319         self.needs_rebuild = true;
320     }
321 
322     fn zoom_at(&mut self, s: (f32, f32), factor: f32) {
323         let area = self.metrics().area;
324         let before = self.to_world(area, s);
325         self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
326         let after = self.to_world(area, s);
327         self.cam.0 += before.0 - after.0;
328         self.cam.1 += before.1 - after.1;
329         self.refit_on_settle = false;
330         self.needs_rebuild = true;
331     }
332 
333     fn node_at(&self, s: (f32, f32)) -> Option<usize> {
334         let area = self.metrics().area;
335         if !area.contains(s.0, s.1) {
336             return None;
337         }
338         let w = self.to_world(area, s);
339         self.graph.hit(&self.visible, w.0, w.1, 4.0 / self.zoom)
340     }
341 
342     fn open(&mut self, i: usize) {
343         let node = &self.graph.nodes[i];
344         if node.ghost {
345             self.status = Some(format!("“{}” does not exist yet — follow a link to it in cce-notes to create it", node.name));
346             return;
347         }
348         let Some(ix) = &self.index else { return };
349         if let Err(e) = open_in_notes(ix.root(), &ix.abs(&node.path)) {
350             self.status = Some(e);
351         }
352     }
353 
354     fn paint_band(&self, pc: &mut PaintCtx, m: &Metrics) {
355         let w = self.width as f32;
356         pc.recess_edges(
357             Rect { x: 0.0, y: 0.0, width: w, height: BAND_H },
358             (0.0, 0.0, 0.0, 0.0),
359             cce_ui::layout::bar_wall_width(),
360             (false, false, true, false),
361         );
362         let (family, size) = cce_ui::layout::menubar_font_parsed();
363         let inset = cce_ui::layout::root_plate_inset();
364         let title = match (&self.index, self.local, &self.center) {
365             (Some(_), true, Some(c)) => format!("Local graph · {}", c.trim_end_matches(".md")),
366             (Some(_), true, None) => "Local graph".to_string(),
367             (Some(ix), false, _) => format!("Graph · {}", vault_name(ix.root())),
368             (None, _, _) => "Graph".to_string(),
369         };
370         let ty = cce_ui::layout::align_text_y(0.0, BAND_H, size, 0.0);
371         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]));
372         let chip = |pc: &mut PaintCtx, r: Rect, label: &str, on: bool| {
373             pc.rounded_rect(r, 6.0, (true, true, true, true), if on { accent(0.25) } else { [1.0, 1.0, 1.0, 0.06] });
374             let lw = label.chars().count() as f32 * size * 0.6;
375             pc.text_with(
376                 label.to_string(),
377                 r.x + ((r.width - lw) / 2.0).max(2.0),
378                 cce_ui::layout::align_text_y(r.y, r.height, size, 0.0),
379                 size,
380                 srgb_u8(if on { FG } else { DIM }),
381                 Some(family.clone()),
382                 Some([r.x, r.y, r.x + r.width, r.y + r.height]),
383             );
384         };
385         chip(pc, m.global_chip, "Global", !self.local);
386         chip(pc, m.local_chip, "Local", self.local);
387         if self.local {
388             chip(pc, m.depth_chip, &format!("Depth {}", self.depth), false);
389         }
390         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.filter_input], pc);
391     }
392 
393     fn paint_status(&self, pc: &mut PaintCtx) {
394         let (w, h) = (self.width as f32, self.height as f32);
395         let y = h - STATUS_H;
396         pc.recess_edges(
397             Rect { x: 0.0, y, width: w, height: STATUS_H },
398             (0.0, 0.0, 0.0, 0.0),
399             cce_ui::layout::bar_wall_width(),
400             (true, false, false, false),
401         );
402         let (family, size) = cce_ui::layout::statusbar_font_parsed();
403         let inset = cce_ui::layout::root_plate_inset();
404         let ty = cce_ui::layout::align_text_y(y, STATUS_H, size, 0.0);
405         let msg = if let Some(s) = &self.status {
406             s.clone()
407         } else if self.index.is_none() {
408             String::new()
409         } else if self.local && self.center.is_none() {
410             "Open a note in cce-notes; the local graph follows it".to_string()
411         } else {
412             let notes = self.visible.iter().filter(|v| **v).count();
413             let links = self.graph.edges.iter().filter(|(a, b)| self.visible[*a] && self.visible[*b]).count();
414             format!("{notes} notes · {links} links · drag to pin, right-click to unpin, Ctrl+0 to fit")
415         };
416         pc.text_with(msg, inset, ty, size, srgb_u8(DIM), Some(family), Some([inset, y, w - inset, h]));
417     }
418 
419     fn paint_graph(&self, pc: &mut PaintCtx, area: Rect) {
420         if let Some(e) = &self.error {
421             let (family, size) = cce_ui::layout::list_font_parsed();
422             for (i, line) in e.lines().enumerate() {
423                 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);
424             }
425             return;
426         }
427         let g = &self.graph;
428         let vis = &self.visible;
429         let center = if self.local { self.center.as_deref().and_then(|c| g.index_of(c)) } else { None };
430         // While a node is hovered, it and its neighbours stand out and
431         // everything else steps back.
432         let mut lit = vec![false; g.len()];
433         if let Some(h) = self.hover {
434             lit[h] = true;
435             for &j in g.neighbors(h) {
436                 lit[j] = true;
437             }
438         }
439         let focus = self.hover.is_some();
440         let margin = 40.0;
441         let on_screen = |p: (f32, f32)| {
442             p.0 > area.x - margin && p.0 < area.x + area.width + margin && p.1 > area.y - margin && p.1 < area.y + area.height + margin
443         };
444         let line_w = (self.zoom * 1.2).clamp(0.6, 2.0);
445         pc.clip(area, |pc| {
446             for &(a, b) in &g.edges {
447                 if !(vis[a] && vis[b]) {
448                     continue;
449                 }
450                 let (pa, pb) = (self.to_screen(area, g.nodes[a].pos), self.to_screen(area, g.nodes[b].pos));
451                 if !on_screen(pa) && !on_screen(pb) {
452                     continue;
453                 }
454                 let touches = self.hover.is_some_and(|h| h == a || h == b);
455                 let color = if touches {
456                     accent(0.9)
457                 } else if focus {
458                     [1.0, 1.0, 1.0, 0.04]
459                 } else {
460                     [1.0, 1.0, 1.0, 0.16]
461                 };
462                 pc.vector(pa.0, pa.1, pb.0, pb.1, if touches { line_w * 1.5 } else { line_w }, color, Cap::Round);
463             }
464             // Labels fade in from this zoom, so a whole-vault view is
465             // dots and lines and names appear as you zoom in.
466             let labels_from = 1.1;
467             for i in (0..g.len()).filter(|&i| vis[i]) {
468                 let p = self.to_screen(area, g.nodes[i].pos);
469                 if !on_screen(p) {
470                     continue;
471                 }
472                 let r = (g.radius(i) * self.zoom).max(1.5);
473                 let node = &g.nodes[i];
474                 let color = if Some(i) == self.hover || Some(i) == center {
475                     accent(1.0)
476                 } else if node.ghost {
477                     [0.25, 0.25, 0.28, if focus && !lit[i] { 0.3 } else { 1.0 }]
478                 } else if focus && !lit[i] {
479                     [0.45, 0.45, 0.5, 0.25]
480                 } else {
481                     [0.62, 0.62, 0.68, 1.0]
482                 };
483                 pc.circle(p.0, p.1, r, color);
484                 if node.pinned {
485                     pc.circle(p.0, p.1, (r * 0.35).max(1.0), [0.1, 0.1, 0.12, 1.0]);
486                 }
487                 // Labels fade in with zoom; the hovered node and its
488                 // neighbours always show theirs.
489                 let alpha = if lit[i] || Some(i) == center {
490                     1.0
491                 } else if focus {
492                     0.0
493                 } else {
494                     ((self.zoom - labels_from) / 0.5).clamp(0.0, 1.0)
495                 };
496                 if alpha <= 0.02 {
497                     continue;
498                 }
499                 let lw = node.name.chars().count() as f32 * LABEL_SIZE * 0.55;
500                 let color = if node.ghost { DIM } else { FG };
501                 pc.text_faded(
502                     node.name.clone(),
503                     p.0 - lw / 2.0,
504                     p.1 + r + 3.0,
505                     LABEL_SIZE,
506                     srgb_u8(color),
507                     alpha,
508                     Some("sans-serif".to_string()),
509                     Some([area.x, area.y, area.x + area.width, area.y + area.height]),
510                 );
511             }
512         });
513     }
514 }
515 
516 
517 fn vault_name(root: &std::path::Path) -> String {
518     root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
519 }
520 
521 impl Application for VaultApp {
522     type Message = Message;
523 
524     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
525         Some(&self.ui_context)
526     }
527 
528     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
529         Some(&mut self.ui_context)
530     }
531 
532     fn create(sender: cce_ui::engine::AppSender<Message>) -> Self {
533         // The app keeps calloop's sender; `AppSender` converts into it.
534         let sender: calloop::channel::Sender<Message> = sender.into();
535         let args = ARGS.get().expect("args set in run");
536         let root = cce_vault::config::vault_root(args.vault.as_deref()).map_err(|e| {
537             format!("No vault: {e}\nSet `vault {{ path \"~/Notes\" }}` in ~/.config/cce/config.kdl, or pass --vault <dir>.")
538         });
539         let (index, error, watcher) = match root {
540             Ok(root) => match Index::open(&root, true) {
541                 Ok(ix) => {
542                     let tx = sender.clone();
543                     let w = VaultWatcher::spawn(&root, move |paths| {
544                         let _ = tx.send(Message::VaultChanged(paths));
545                     })
546                     .ok();
547                     (Some(ix), None, w)
548                 }
549                 Err(e) => (None, Some(format!("Could not read the vault at {}\n{e}", root.display())), None),
550             },
551             Err(e) => (None, Some(e), None),
552         };
553         spawn_listener(sender.clone());
554         let graph = index.as_ref().map(|ix| LinkGraph::build(ix, None)).unwrap_or_default();
555         let n = graph.len();
556         // The context owns the widgets; the app keeps their handles.
557         let mut ui_context = cce_ui::context::UiContext::new();
558         let mut app = VaultApp {
559             index,
560             error,
561             _watcher: watcher,
562             graph,
563             visible: vec![true; n],
564             local: false,
565             depth: 1,
566             center: None,
567             next_poll: Instant::now(),
568             filter_input: ui_context.insert(TextBox::new(String::new()).with_placeholder("Filter: words, #tag, path:")),
569             filter_seen: String::new(),
570             cam: (0.0, 0.0),
571             zoom: 1.0,
572             fit_pending: true,
573             refit_on_settle: true,
574             hover: None,
575             drag: Drag::None,
576             pointer: (0.0, 0.0),
577             status: None,
578             width: 1000,
579             height: 700,
580             needs_rebuild: true,
581             ui_context,
582             widgets_registered: false,
583         };
584         app.set_mode(args.local, args.note.clone());
585         app
586     }
587 
588     fn settings(&self) -> WindowSettings {
589         WindowSettings {
590             title: "Graph".to_string(),
591             app_id: "cce-graph-vault".to_string(),
592             width: 1000,
593             height: 700,
594             fullscreen: false,
595             min_size: Some((420, 300)),
596         }
597     }
598 
599     fn update(&mut self, msg: Message, needs_rebuild: &mut bool, _exit: &mut bool) {
600         match msg {
601             Message::VaultChanged(paths) => self.vault_changed(paths),
602             Message::Mode { local, note } => self.set_mode(local, note),
603             Message::Exit => *_exit = true,
604         }
605         *needs_rebuild = true;
606     }
607 
608     fn idle_poll_interval(&self) -> Option<Duration> {
609         // The local graph follows cce-notes; nothing else needs waking.
610         self.local.then_some(POLL_EVERY)
611     }
612 
613     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
614         if self.ui_context.tick(dt) {
615             *needs_rebuild = true;
616         }
617         self.poll_center();
618         if !self.graph.settled() {
619             for _ in 0..STEPS_PER_FRAME {
620                 self.graph.step(&self.visible);
621             }
622             if self.fit_pending && self.graph.alpha < 0.35 {
623                 self.fit_pending = false;
624                 self.fit();
625             }
626             *needs_rebuild = true;
627         } else if self.fit_pending || self.refit_on_settle {
628             self.fit_pending = false;
629             self.refit_on_settle = false;
630             self.fit();
631             *needs_rebuild = true;
632         }
633         if self.needs_rebuild {
634             *needs_rebuild = true;
635         }
636     }
637 
638     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
639         if !self.widgets_registered {
640             self.widgets_registered = true;
641         }
642         let resized = self.width != size.width as u32 || self.height != size.height as u32;
643         self.width = size.width as u32;
644         self.height = size.height as u32;
645         cce_ui::scale::set_scale_factor(scale as f32);
646         let m = self.metrics();
647         if self.needs_rebuild || resized {
648             let f = m.filter;
649             self.ui_context[self.filter_input].set_rect(f.x, f.y, f.width, f.height);
650             self.ui_context.rebuild_spatial_grid();
651             self.needs_rebuild = false;
652         }
653         let mut pc = PaintCtx::new();
654         pc.root_plate(size.width as f32, size.height as f32);
655         self.paint_graph(&mut pc, m.area);
656         self.paint_band(&mut pc, &m);
657         self.paint_status(&mut pc);
658         Some(pc.finish())
659     }
660 
661     fn display_list_text(&self) -> bool {
662         true
663     }
664 
665     fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
666         let m = self.metrics();
667         if m.filter.contains(x, y) {
668             return Some(CursorIcon::Text);
669         }
670         match self.drag {
671             Drag::Pan { .. } => Some(CursorIcon::Grabbing),
672             Drag::Node { .. } => Some(CursorIcon::Grabbing),
673             Drag::None if self.hover.is_some() => Some(CursorIcon::Pointer),
674             Drag::None => None,
675         }
676     }
677 
678     fn on_exit(&mut self) {
679         if let Some(ix) = self.index.as_mut() {
680             let _ = ix.save_cache();
681         }
682     }
683 
684     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
685         let s = (pos.x as f32, pos.y as f32);
686         self.pointer = s;
687         let area = self.metrics().area;
688         match &mut self.drag {
689             Drag::Pan { last } => {
690                 let (dx, dy) = (s.0 - last.0, s.1 - last.1);
691                 *last = s;
692                 self.cam.0 -= dx / self.zoom;
693                 self.cam.1 -= dy / self.zoom;
694                 self.refit_on_settle = false;
695                 *needs_rebuild = true;
696                 return;
697             }
698             Drag::Node { i, from, moved } => {
699                 let i = *i;
700                 if !*moved && ((s.0 - from.0).abs() > CLICK_SLOP || (s.1 - from.1).abs() > CLICK_SLOP) {
701                     *moved = true;
702                 }
703                 if *moved {
704                     let w = self.to_world(area, s);
705                     let node = &mut self.graph.nodes[i];
706                     node.pos = w;
707                     node.pinned = true;
708                     self.graph.reheat(0.25);
709                     *needs_rebuild = true;
710                 }
711                 return;
712             }
713             Drag::None => {}
714         }
715         let h = self.node_at(s);
716         if h != self.hover {
717             self.hover = h;
718             *needs_rebuild = true;
719         }
720         let ev = Event::PointerMove { x: s.0, y: s.1, local_x: s.0, local_y: s.1 };
721         if self.ui_context.propagate_event(&ev, self.filter_input.id()) {
722             *needs_rebuild = true;
723         }
724     }
725 
726     fn handle_mouse_input(
727         &mut self,
728         button: MouseButton,
729         state: ElementState,
730         pos: LogicalPosition,
731         needs_rebuild: &mut bool,
732     ) -> Option<Message> {
733         let s = (pos.x as f32, pos.y as f32);
734         *needs_rebuild = true;
735         let m = self.metrics();
736         let ev = Event::MouseButton { button, state, x: s.0, y: s.1, local_x: s.0, local_y: s.1 };
737         let pressed = state == ElementState::Pressed;
738 
739         if m.filter.contains(s.0, s.1) {
740             if pressed && !self.ui_context[self.filter_input].editing {
741                 self.ui_context.set_focused_id(self.filter_input.id());
742                 WidgetHost::focus(&mut self.ui_context[self.filter_input]);
743             }
744             self.ui_context.propagate_event(&ev, self.filter_input.id());
745             return None;
746         }
747         if pressed && self.ui_context[self.filter_input].editing {
748             self.ui_context.unfocus_id(self.filter_input.id());
749         }
750         if pressed && button == MouseButton::Left {
751             if m.global_chip.contains(s.0, s.1) {
752                 self.set_mode(false, None);
753                 return None;
754             }
755             if m.local_chip.contains(s.0, s.1) {
756                 self.set_mode(true, None);
757                 return None;
758             }
759             if self.local && m.depth_chip.contains(s.0, s.1) {
760                 self.depth = self.depth % 4 + 1;
761                 self.recompute_visible();
762                 self.fit_pending = true;
763                 return None;
764             }
765         }
766         if !m.area.contains(s.0, s.1) && pressed {
767             return None;
768         }
769         match (button, state) {
770             (MouseButton::Left, ElementState::Pressed) => {
771                 self.drag = match self.node_at(s) {
772                     Some(i) => Drag::Node { i, from: s, moved: false },
773                     None => Drag::Pan { last: s },
774                 };
775                 self.status = None;
776             }
777             (MouseButton::Left, ElementState::Released) => {
778                 if let Drag::Node { i, moved: false, .. } = self.drag {
779                     self.open(i);
780                 }
781                 self.drag = Drag::None;
782                 self.hover = self.node_at(s);
783             }
784             (MouseButton::Right, ElementState::Pressed) => {
785                 if let Some(i) = self.node_at(s) {
786                     self.graph.nodes[i].pinned = false;
787                     self.graph.reheat(0.3);
788                 }
789             }
790             _ => {}
791         }
792         None
793     }
794 
795     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
796         let s = (pos.x as f32, pos.y as f32);
797         if !self.metrics().area.contains(s.0, s.1) {
798             return;
799         }
800         match delta {
801             // A wheel notch zooms about the pointer, as Obsidian's does.
802             MouseScrollDelta::LineDelta(_, y) => self.zoom_at(s, 1.15f32.powf(*y)),
803             // A trackpad pans; with Ctrl (a pinch on most stacks) it zooms.
804             MouseScrollDelta::PixelDelta(p) => {
805                 if self.ui_context.ctrl_pressed {
806                     self.zoom_at(s, 1.01f32.powf(p.y as f32));
807                 } else {
808                     self.cam.0 -= p.x as f32 / self.zoom;
809                     self.cam.1 -= p.y as f32 / self.zoom;
810                     self.refit_on_settle = false;
811                 }
812             }
813         }
814         *needs_rebuild = true;
815     }
816 
817     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
818         *needs_rebuild = true;
819         let pressed = event.state == ElementState::Pressed;
820         if self.ui_context[self.filter_input].editing {
821             if pressed && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
822                 self.ui_context.unfocus_id(self.filter_input.id());
823                 return None;
824             }
825             let ev = Event::KeyInput(event.clone());
826             self.ui_context.propagate_event(&ev, self.filter_input.id());
827             let q = self.filter_query();
828             if q != self.filter_seen {
829                 self.filter_seen = q;
830                 self.recompute_visible();
831             }
832             return None;
833         }
834         if !pressed {
835             return None;
836         }
837         let k = |name: &str, default: &str| cce_ui::widget::match_key_shortcut(event, &cce_ui::input::app_chord(name, default));
838         if k("toggle_local", "ctrl+l") {
839             let local = !self.local;
840             self.set_mode(local, None);
841         } else if k("fit", "ctrl+0") {
842             self.fit();
843         } else if k("filter", "ctrl+f") {
844             self.ui_context.set_focused_id(self.filter_input.id());
845             WidgetHost::focus(&mut self.ui_context[self.filter_input]);
846         } else if k("depth_more", "ctrl+=") || k("depth_more_plus", "ctrl+shift+=") {
847             self.depth = (self.depth + 1).min(4);
848             self.recompute_visible();
849             self.fit_pending = true;
850         } else if k("depth_less", "ctrl+-") {
851             self.depth = self.depth.saturating_sub(1).max(1);
852             self.recompute_visible();
853             self.fit_pending = true;
854         } else if k("quit", "ctrl+q") {
855             return Some(Message::Exit);
856         }
857         None
858     }
859 }
860 
861 /// Run the vault graph, or hand the launch to the one already running.
862 pub fn run(args: Args) {
863     if forward_or_claim(&args) {
864         return;
865     }
866     let _ = ARGS.set(args);
867     cce_ui::engine::run::<VaultApp>();
868     cce_ui::ipc::instance::cleanup();
869 }
870 
871 #[cfg(test)]
872 mod tests {
873     use super::*;
874 
875     fn args(a: &[&str]) -> Vec<String> {
876         a.iter().map(|s| s.to_string()).collect()
877     }
878 
879     #[test]
880     fn vault_arguments() {
881         assert!(Args::parse(&args(&[])).is_none());
882         assert!(Args::parse(&args(&["project/dir"])).is_none());
883         let a = Args::parse(&args(&["--vault"])).unwrap();
884         assert!(a.vault.is_none() && !a.local);
885         let a = Args::parse(&args(&["--vault", "/v", "--local", "Note"])).unwrap();
886         assert_eq!(a.vault.as_deref(), Some(std::path::Path::new("/v")));
887         assert!(a.local);
888         assert_eq!(a.note.as_deref(), Some("Note"));
889         let a = Args::parse(&args(&["--vault", "--local"])).unwrap();
890         assert!(a.vault.is_none() && a.local && a.note.is_none());
891         assert_eq!(launch_line(&a), "local");
892     }
893 }