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 }