commit 791b0f07ecc049432fda6c1de6ca19b7144cca0d
Author: Matthew Gantenbein <ganten@Matthews-MBP.ht.home>
Date: Wed, 20 May 2026 22:17:10 -0500
Navi 1.0.0 — Rust rewrite
Ground-up rewrite from Python (pygame + ModernGL) to Rust (egui + glow).
The previous Python release is superseded.
Highlights:
- navi-core (config, db, graph + physics) and navi (egui UI) Cargo workspace.
- Hand-rolled winit + glutin + egui_glow event loop replacing eframe.
- 240 Hz vsync-aligned pacer on macOS via a dedicated CADisplayLink thread,
CAFrameRateRange-pinned to the panel's max refresh rate. Locked 4.17 ms
frame interval, sub-millisecond worst-case variance.
- Configurable idle-grace window (NAVI_IDLE_GRACE_SECS, default 10 s) drops
to ~30 fps when the window loses focus or input is idle, then snaps back
to full speed within one frame on resume.
- Paint coalescing prevents duplicate redraw triggers from contending for
the same compositor slot.
Co-authored-by: Cursor <cursoragent@cursor.com>
Diffstat:
17 files changed, 6944 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,12 @@
+# Rust build output
+/target/
+
+# Editor / OS
+.DS_Store
+.idea/
+.vscode/
+*.swp
+
+# Local config / secrets
+.env
+.env.local
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -0,0 +1,44 @@
+# Changelog
+
+All notable changes to Navi are documented here.
+
+## [1.0.0] — 2026-05-20
+
+Full ground-up rewrite from Python (pygame + ModernGL) to Rust (egui + glow). Same conceptual feature set, dramatically better performance and packaging. The previous Python release is superseded and unsupported.
+
+### Added
+
+- **Rust workspace** — `navi-core` (pure library: config, db loader, graph, physics) + `navi` (egui UI binary).
+- **Native event loop** — direct `winit` + `glutin` + `egui_glow`, replacing eframe. Gives Navi full control over the paint cadence.
+- **Vsync-aligned 240 Hz pacer (macOS)** — dedicated background thread owns a `CADisplayLink` pinned to the panel's max refresh rate via `CAFrameRateRange`. Each vsync sends a `UserEvent::Vsync` to the main loop via `EventLoopProxy`; mouse/keyboard events never trigger redraws. Result: locked 4.17 ms frame interval on a 240 Hz display with no missed vsyncs in steady state.
+- **Idle grace window** — after ~10 s of inactivity (configurable via `NAVI_IDLE_GRACE_SECS`) the display link pauses and the OS drops the panel tier; the next input or focus event resumes within one frame.
+- **Paint coalescing** — duplicate redraw triggers (e.g. a Vsync arriving microseconds after a focus-driven redraw) within half a vsync interval are dropped, preventing compositor-slot contention that produced 8–14 ms worst-case frames.
+- **`NAVI_FPS_LOG=1`** — stderr probe printing fps, avg/worst frame ms, deque size, and display-link metadata once per second.
+- **`NAVI_PROF=1`** — per-layer paint timing (grid / edges / nodes / labels / help).
+- **Graph rendering via egui's tessellator + glow** — single-pass painter with grid, edges, nodes, labels, particle effects, and help overlay.
+- **Themes** — Obsidian / Forest / Ocean / Ember / Mono, cycled with `T`.
+- **Layouts** — force-directed (default) + alternates cycled with `V`.
+- **Search** — `/` to search nodes by title or alias.
+- **Local-graph mode** — `L` cycles 1 → 2 → 3 hops → off.
+- **Filters** — `D` (daily notes) and `O` (orphans).
+- **Tag colouring** — `G`; reads org-roam's `tags` table, golden-ratio hue spacing.
+- **Age heatmap** — `A`; visualises file mtime in 6 stages.
+- **Headline-level nodes** — open jumps to heading position via `goto-char`.
+- **emacsclient discovery** — Homebrew, MacPorts, `/usr/local/bin`, `/usr/bin`, `~/.local/bin`, `~/.nix-profile/bin`, NixOS, Snap, `/Applications/Emacs.app`.
+- **Emacs socket discovery** — `$EMACS_SERVER_SOCKET`, `$XDG_RUNTIME_DIR/emacs/`, `$TMPDIR/emacs{uid}/`, `/tmp`, `/private/tmp`, and `/var/folders/.../T/emacs{uid}/` (fixes GUI vs Terminal `TMPDIR` mismatch on macOS).
+- **DB auto-detection** — vanilla Emacs, XDG, Doom 2.x / 3.x, Spacemacs.
+
+### Removed
+
+- Python launcher (`navi`, `navi.py`, `org-roam-graph-window`).
+- pygame / ModernGL / Pillow / numpy dependencies.
+- PyInstaller `.app` bundle build (`build/build-macos.sh`, `dist/Navi.app`). To be replaced by a Cargo-driven `.app` build in a later release.
+- Borderless mode + AeroSpace integration. Will return as a follow-up once the `winit` macOS path supports the same NSWindow tweaks.
+- `--check` preflight CLI flag. Will return as `cargo run -- --check`.
+- Particle effects on the GPU compute path. Currently absent from the Rust port.
+
+### Notes
+
+- This release reuses the `v1.0.0` tag. The previous Python `v1.0.0` was deleted.
+- Linux compiles but does not yet have a vsync source wired up; the macOS-only `CADisplayLink` path is gated behind `cfg(target_os = "macos")`. Tracking issue to follow.
+- Windows is not supported.
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,3102 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.11.1",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.11.1",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+dependencies = [
+ "bitflags 2.11.1",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "tracing",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop 0.13.0",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
+dependencies = [
+ "calloop 0.14.4",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "cgl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "libc",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "ecolor"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b"
+dependencies = [
+ "bytemuck",
+ "emath",
+]
+
+[[package]]
+name = "egui"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974"
+dependencies = [
+ "ahash",
+ "emath",
+ "epaint",
+ "log",
+ "nohash-hasher",
+]
+
+[[package]]
+name = "egui-winit"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6"
+dependencies = [
+ "ahash",
+ "arboard",
+ "egui",
+ "log",
+ "raw-window-handle",
+ "smithay-clipboard",
+ "web-time",
+ "webbrowser",
+ "winit",
+]
+
+[[package]]
+name = "egui_extras"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1"
+dependencies = [
+ "ahash",
+ "egui",
+ "enum-map",
+ "image",
+ "log",
+ "mime_guess2",
+]
+
+[[package]]
+name = "egui_glow"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "egui",
+ "egui-winit",
+ "glow",
+ "log",
+ "memoffset",
+ "wasm-bindgen",
+ "web-sys",
+ "winit",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "emath"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "enum-map"
+version = "2.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+dependencies = [
+ "enum-map-derive",
+ "serde",
+]
+
+[[package]]
+name = "enum-map-derive"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "epaint"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f"
+dependencies = [
+ "ab_glyph",
+ "ahash",
+ "bytemuck",
+ "ecolor",
+ "emath",
+ "epaint_default_fonts",
+ "log",
+ "nohash-hasher",
+ "parking_lot",
+]
+
+[[package]]
+name = "epaint_default_fonts"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glow"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
+dependencies = [
+ "bitflags 2.11.1",
+ "cfg_aliases",
+ "cgl",
+ "dispatch2",
+ "glutin_egl_sys",
+ "glutin_glx_sys",
+ "glutin_wgl_sys",
+ "libloading",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "raw-window-handle",
+ "wayland-sys",
+ "windows-sys 0.52.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin-winit"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
+dependencies = [
+ "cfg_aliases",
+ "glutin",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "glutin_egl_sys"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
+dependencies = [
+ "gl_generator",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "glutin_glx_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
+dependencies = [
+ "gl_generator",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "bitflags 2.11.1",
+ "libc",
+ "plain",
+ "redox_syscall 0.7.5",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess2"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
+dependencies = [
+ "mime",
+ "phf",
+ "phf_shared",
+ "unicase",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "navi"
+version = "1.0.0"
+dependencies = [
+ "egui",
+ "egui-winit",
+ "egui_extras",
+ "egui_glow",
+ "glow",
+ "glutin",
+ "glutin-winit",
+ "navi-core",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "raw-window-handle",
+ "regex",
+ "winit",
+]
+
+[[package]]
+name = "navi-core"
+version = "1.0.0"
+dependencies = [
+ "dirs",
+ "rayon",
+ "regex",
+ "rusqlite",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.11.1",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "libc",
+ "objc2 0.5.2",
+ "objc2-core-data 0.2.2",
+ "objc2-core-image 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core 0.2.2",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.6.2",
+ "libc",
+ "objc2 0.6.4",
+ "objc2-cloud-kit 0.3.2",
+ "objc2-core-data 0.3.2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image 0.3.2",
+ "objc2-core-text",
+ "objc2-core-video",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.11.1",
+ "dispatch2",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.11.1",
+ "dispatch2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-core-video"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "dispatch",
+ "libc",
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.6.2",
+ "libc",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.6.2",
+ "libc",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-video",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-cloud-kit 0.2.2",
+ "objc2-core-data 0.2.2",
+ "objc2-core-image 0.2.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core 0.2.2",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "orbclient"
+version = "0.3.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a570f6bca41d29acb2139229a7c873ec99bc9a313bd10804081d89bfac8ff329"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "unicase",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+ "unicase",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rusqlite"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
+dependencies = [
+ "bitflags 2.11.1",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.11.1",
+ "calloop 0.13.0",
+ "calloop-wayland-source 0.3.0",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
+dependencies = [
+ "bitflags 2.11.1",
+ "calloop 0.14.4",
+ "calloop-wayland-source 0.4.1",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 1.1.4",
+ "thiserror 2.0.18",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-experimental",
+ "wayland-protocols-misc",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit 0.20.0",
+ "wayland-backend",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.11+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.4",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.11.1",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.11.1",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.4",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-experimental"
+version = "20250721.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webbrowser"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
+dependencies = [
+ "core-foundation 0.10.1",
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+ "url",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winit"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "bytemuck",
+ "calloop 0.13.0",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation 0.9.4",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "smithay-client-toolkit 0.19.2",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "libloading",
+ "once_cell",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xcursor"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
+
+[[package]]
+name = "xkbcommon-dl"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
+dependencies = [
+ "bitflags 2.11.1",
+ "dlib",
+ "log",
+ "once_cell",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,3 @@
+[workspace]
+members = ["navi-core", "navi"]
+resolver = "2"
diff --git a/README.md b/README.md
@@ -0,0 +1,179 @@
+# Navi
+
+An interactive graph viewer for [org-roam](https://www.orgroam.com/), running as a native desktop window. No Emacs package required — Navi reads your `org-roam.db` directly and opens nodes in your existing Emacs process via `emacsclient`.
+
+Written in Rust on top of [egui](https://github.com/emilk/egui) + [glow](https://github.com/grovesNL/glow) (OpenGL). On macOS it ships a hand-rolled vsync-aligned event loop that locks the renderer to the panel's native refresh rate (60, 120, 240 Hz) with sub-millisecond frame time variance, then drops to ~30 fps when idle to save power on laptops.
+
+---
+
+## Status
+
+- **macOS** — primary target, fully supported. Tested on Apple Silicon (M-series) at 60 / 120 / 240 Hz.
+- **Linux** — builds cleanly; the macOS-only `CADisplayLink` pacer is gated, so a small amount of glue (using `SwapInterval::Wait(1)` or Wayland presentation-time / DRM vblank) is required before it renders. Everything else (egui, winit, glutin, sqlite, emacsclient discovery) is cross-platform.
+- **Windows** — not supported. The `emacsclient` socket discovery is Unix-only and the macOS pacer would have to be replaced by DXGI waitable swap chains.
+
+---
+
+## Quick start
+
+```bash
+git clone https://github.com/ganten7/navi.git
+cd navi
+cargo build --release
+./target/release/navi
+```
+
+On first run, Navi auto-detects your `org-roam.db` and creates `~/.config/navi/config.json`. Subsequent launches start in well under a second.
+
+---
+
+## Requirements
+
+- **Rust 1.75+** (stable) — install via [rustup](https://rustup.rs/)
+- **org-roam v2** database (`nodes`, `files`, `links`, `tags`, `aliases`)
+- **emacsclient** + a running Emacs server (`(server-start)` in your init) — for double-click-to-open
+- A working OpenGL 3.3 context — built into macOS / standard on Linux
+
+---
+
+## Configuration
+
+Config file: `~/.config/navi/config.json`. Created on first run with auto-detected defaults.
+
+```json
+{
+ "db": "~/.emacs.d/org-roam.db",
+ "emacsclient": "/opt/homebrew/bin/emacsclient",
+ "server_name": "server",
+ "show_fps": true
+}
+```
+
+| Key | Description |
+|---|---|
+| `db` | Path to `org-roam.db`. Auto-detected from common Emacs / Doom / Spacemacs / XDG locations on first run. |
+| `emacsclient` | Path to `emacsclient`. Bare names are resolved against Homebrew, MacPorts, `/usr/local/bin`, `/usr/bin`, `~/.local/bin`, `~/.nix-profile/bin`, NixOS, Snap, and `/Applications/Emacs.app`. |
+| `server_name` | Emacs server name (default `server`). |
+| `show_fps` | Show FPS counter in the status bar (`F` toggles at runtime). |
+
+The legacy config path `~/.config/org-roam-graph/config.json` is also read; the next save writes to the new path.
+
+### DB auto-detection order
+
+| Path | Setup |
+|---|---|
+| `$ORG_ROAM_DB` | env override |
+| `$XDG_DATA_HOME/emacs/org-roam.db` | XDG-strict Linux |
+| `~/.emacs.d/org-roam.db` | vanilla Emacs |
+| `~/.config/emacs/org-roam.db` | XDG-style Emacs |
+| `~/.config/doom/.local/etc/org-roam.db` | Doom 3.x |
+| `~/.config/doom/org-roam.db` | Doom 3.x fallback |
+| `~/.doom.d/.local/etc/org-roam.db` | Doom 2.x |
+| `~/.doom.d/org-roam.db` | Doom 2.x fallback |
+| `~/.spacemacs.d/org-roam.db` | Spacemacs |
+
+---
+
+## Opening nodes in Emacs
+
+Double-click a node (or select it and press `Enter` / `Space`). File nodes open the file; **headline nodes jump to the heading** via `goto-char`.
+
+GUI apps on macOS get a minimal `PATH`, so Navi resolves `emacsclient` to an absolute path and probes the server socket under:
+
+- `$EMACS_SERVER_SOCKET` / `$EMACS_SERVER_FILE`
+- `$XDG_RUNTIME_DIR/emacs/`
+- `$TMPDIR/emacs{uid}/`
+- `/tmp`, `/private/tmp`
+- `/var/folders/*/*/T/emacs{uid}/` (macOS GUI vs Terminal `TMPDIR` mismatch)
+
+If open fails, an error appears in the status bar. Make sure Emacs has `(server-start)` in its init.
+
+---
+
+## Controls
+
+| Input | Action |
+|---|---|
+| Drag background | Pan view |
+| Swipe + release | Kinetic pan (momentum) |
+| Drag node | Move node |
+| Scroll / trackpad | Zoom toward cursor |
+| Click node | Select — highlights connections |
+| Double-click node | Open in Emacs |
+| `Tab` / `Shift-Tab` | Cycle nodes |
+| `Enter` / `Space` | Open selected node |
+| `T` | Cycle colour theme |
+| `G` | Toggle tag colouring |
+| `A` | Toggle age / weathering heatmap |
+| `D` | Toggle daily-notes filter |
+| `O` | Toggle orphan filter |
+| `L` | Cycle local-graph mode (1 → 2 → 3 hops → off) |
+| `V` | Cycle layout algorithm |
+| `/` | Search by title or alias |
+| `W` | Reload graph from database |
+| `F` | Toggle FPS counter |
+| `P` | Pause / resume physics |
+| `R` | Reset view |
+| `H` | Hold to show controls panel |
+| `Q` / `Escape` | Quit |
+
+---
+
+## Frame pacing
+
+On macOS Navi runs a hand-rolled event loop:
+
+- A dedicated background thread owns a `CADisplayLink` pinned to the panel's max refresh rate via `CAFrameRateRange`. On each vsync it sends a `UserEvent::Vsync` to the main thread via winit's `EventLoopProxy`.
+- Mouse / keyboard events do **not** trigger paints. Display vsync ticks are the sole paint signal — this eliminates the multi-paint-per-frame burn that input-driven loops produce.
+- After ~10 s of inactivity (configurable with `NAVI_IDLE_GRACE_SECS`), the link pauses and the OS drops the panel to its lowest tier. The next input or focus event resumes the link within a frame.
+- OpenGL's `swap_buffers` is called with `SwapInterval::DontWait` — macOS GL caps swap-vsync at ~108 Hz on ProMotion panels regardless of the displayed tier, so the display-link does the pacing instead.
+
+Result on a 240 Hz display: 4.17 ms average frame interval, worst-case 5–7 ms (no missed vsyncs in steady state), and the application sits near 0 % CPU when idle.
+
+### Diagnostics
+
+| Env var | Effect |
+|---|---|
+| `NAVI_FPS_LOG=1` | Print fps + frame stats + display-link metadata to stderr every ~1 s |
+| `NAVI_PROF=1` | Print per-layer paint timing (grid/edges/nodes/labels/help) every ~1 s |
+| `NAVI_IDLE_GRACE_SECS=N` | Override the 10 s active-after-idle grace window |
+| `NAVI_NO_GRID`, `NAVI_NO_EDGES`, `NAVI_NO_NODES`, `NAVI_NO_LABELS` | Disable individual paint layers (perf debugging) |
+
+---
+
+## Project layout
+
+```
+Cargo.toml Workspace root
+navi-core/ Pure-Rust library: config, org-roam DB loader, graph + physics
+ src/lib.rs
+ src/config.rs Config load/save, db detection, path expansion
+ src/emacs.rs emacsclient + socket discovery
+ src/graph.rs Force-directed physics, layouts, hidden/faded sets
+navi/ Binary: UI, rendering, event loop
+ src/main.rs winit + glutin event loop, paint pacer
+ src/app.rs egui app: input, layout, status bar
+ src/painter.rs GraphPainter — grid, edges, nodes, labels
+ src/macos_display.rs macOS CADisplayLink + tier control
+ src/theme.rs Colour themes
+```
+
+---
+
+## Building
+
+```bash
+# Release (recommended)
+cargo build --release
+
+# Dev (slower at runtime, faster compile, includes debuginfo)
+cargo build
+```
+
+The release profile in the workspace has `lto = true` and `opt-level = 3`. Expect a 30–60 s clean release build on a modern laptop.
+
+---
+
+## License
+
+Source release. Add a `LICENSE` file if you intend to distribute binaries.
diff --git a/navi-core/Cargo.toml b/navi-core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "navi-core"
+version = "1.0.0"
+edition = "2021"
+
+[dependencies]
+rusqlite = { version = "0.31", features = ["bundled"] }
+rayon = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+dirs = "5"
+regex = "1"
diff --git a/navi-core/src/config.rs b/navi-core/src/config.rs
@@ -0,0 +1,105 @@
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Config {
+ #[serde(default)]
+ pub db: String,
+ #[serde(default)]
+ pub emacsclient: String,
+ #[serde(default = "default_server")]
+ pub server_name: String,
+ #[serde(default = "default_true")]
+ pub show_fps: bool,
+ #[serde(default)]
+ pub borderless: bool,
+}
+
+fn default_server() -> String { "server".into() }
+fn default_true() -> bool { true }
+
+impl Default for Config {
+ fn default() -> Self {
+ Config {
+ db: String::new(),
+ emacsclient: String::new(),
+ server_name: "server".into(),
+ show_fps: true,
+ borderless: false,
+ }
+ }
+}
+
+impl Config {
+ pub fn config_path() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_default()
+ .join(".config")
+ .join("navi")
+ .join("config.json")
+ }
+
+ pub fn load() -> Self {
+ let path = Self::config_path();
+ if path.exists() {
+ if let Ok(s) = std::fs::read_to_string(&path) {
+ if let Ok(c) = serde_json::from_str(&s) {
+ return c;
+ }
+ }
+ }
+ Config::default()
+ }
+
+ pub fn save(&self) {
+ let path = Self::config_path();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ if let Ok(s) = serde_json::to_string_pretty(self) {
+ let _ = std::fs::write(&path, s);
+ }
+ }
+}
+
+// ── DB path detection ─────────────────────────────────────────────────────────
+
+static DB_CANDIDATES: &[&str] = &[
+ "~/.emacs.d/org-roam.db",
+ "~/.config/emacs/org-roam.db",
+ "~/.config/doom/.local/etc/org-roam.db",
+ "~/.config/doom/org-roam.db",
+ "~/.doom.d/.local/etc/org-roam.db",
+ "~/.doom.d/org-roam.db",
+ "~/.spacemacs.d/org-roam.db",
+];
+
+pub fn detect_db() -> String {
+ if let Ok(v) = std::env::var("ORG_ROAM_DB") {
+ if std::path::Path::new(&v).exists() {
+ return v;
+ }
+ }
+ // XDG_DATA_HOME
+ let xdg = std::env::var("XDG_DATA_HOME")
+ .unwrap_or_else(|_| format!("{}/.local/share", dirs::home_dir().unwrap_or_default().display()));
+ let xdg_db = format!("{}/emacs/org-roam.db", xdg);
+ if std::path::Path::new(&xdg_db).exists() {
+ return xdg_db;
+ }
+ for cand in DB_CANDIDATES {
+ let expanded = expand_tilde(cand);
+ if std::path::Path::new(&expanded).exists() {
+ return expanded;
+ }
+ }
+ expand_tilde(DB_CANDIDATES[0])
+}
+
+pub fn expand_tilde(path: &str) -> String {
+ if let Some(rest) = path.strip_prefix("~/") {
+ let home = dirs::home_dir().unwrap_or_default();
+ return format!("{}/{}", home.display(), rest);
+ }
+ path.to_string()
+}
diff --git a/navi-core/src/db.rs b/navi-core/src/db.rs
@@ -0,0 +1,143 @@
+use rusqlite::{Connection, OpenFlags};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone)]
+pub struct RawNode {
+ pub id: String,
+ pub title: String,
+ pub file: String,
+ pub level: i64,
+ pub pos: i64,
+ pub mtime: i64,
+ pub aliases: Vec<String>,
+ pub tags: Vec<String>,
+}
+
+pub fn load_graph(db_path: &str) -> rusqlite::Result<(Vec<RawNode>, Vec<(String, String)>)> {
+ let conn = Connection::open_with_flags(
+ db_path,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
+ )?;
+
+ let mut nodes: HashMap<String, RawNode> = HashMap::new();
+
+ // Try with files JOIN first; mtime may be Emacs `(HIGH LOW ...)` string
+ let rows_result = conn.prepare(
+ "SELECT n.id, n.title, n.file, n.level, n.pos, f.mtime
+ FROM nodes n LEFT JOIN files f ON n.file = f.file",
+ );
+
+ match rows_result {
+ Ok(mut stmt) => {
+ let iter = stmt.query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, Option<String>>(1)?.unwrap_or_default(),
+ row.get::<_, Option<String>>(2)?.unwrap_or_default(),
+ row.get::<_, Option<i64>>(3)?.unwrap_or(0),
+ row.get::<_, Option<i64>>(4)?.unwrap_or(0),
+ row.get::<_, Option<String>>(5)?, // may be "(HIGH LOW ...)" or integer
+ ))
+ })?;
+ for row in iter.flatten() {
+ let (id, title, file, level, pos, mtime_raw) = row;
+ let title = title.trim_matches('"').to_string();
+ let file = file.trim_matches('"').to_string();
+ let mtime = parse_mtime(mtime_raw.as_deref());
+ nodes.insert(id.clone(),
+ RawNode { id, title, file, level, pos, mtime, aliases: vec![], tags: vec![] });
+ }
+ }
+ Err(_) => {
+ let mut stmt = conn.prepare(
+ "SELECT id, COALESCE(title,''), COALESCE(file,''), COALESCE(level,0), COALESCE(pos,0) FROM nodes",
+ )?;
+ let iter = stmt.query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, i64>(3)?,
+ row.get::<_, i64>(4)?,
+ ))
+ })?;
+ for row in iter.flatten() {
+ let (id, title, file, level, pos) = row;
+ let title = title.trim_matches('"').to_string();
+ let file = file.trim_matches('"').to_string();
+ nodes.insert(id.clone(),
+ RawNode { id, title, file, level, pos, mtime: 0, aliases: vec![], tags: vec![] });
+ }
+ }
+ }
+
+ // Edges
+ let mut edges: Vec<(String, String)> = Vec::new();
+ if let Ok(mut stmt) = conn.prepare("SELECT source, dest FROM links") {
+ let iter = stmt.query_map([], |row| {
+ Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
+ });
+ if let Ok(iter) = iter {
+ for row in iter.flatten() {
+ if nodes.contains_key(&row.0) && nodes.contains_key(&row.1) {
+ edges.push(row);
+ }
+ }
+ }
+ }
+
+ // Aliases
+ if let Ok(mut stmt) = conn.prepare("SELECT node_id, alias FROM aliases") {
+ let iter = stmt.query_map([], |row| {
+ Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
+ });
+ if let Ok(iter) = iter {
+ for row in iter.flatten() {
+ if let Some(n) = nodes.get_mut(&row.0) {
+ let alias = row.1.trim_matches('"').to_string();
+ if !alias.is_empty() {
+ n.aliases.push(alias);
+ }
+ }
+ }
+ }
+ }
+
+ // Tags
+ if let Ok(mut stmt) = conn.prepare("SELECT node_id, tag FROM tags") {
+ let iter = stmt.query_map([], |row| {
+ Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
+ });
+ if let Ok(iter) = iter {
+ for row in iter.flatten() {
+ if let Some(n) = nodes.get_mut(&row.0) {
+ if !row.1.is_empty() {
+ n.tags.push(row.1);
+ }
+ }
+ }
+ }
+ }
+
+ let node_list: Vec<RawNode> = nodes.into_values().collect();
+ Ok((node_list, edges))
+}
+
+/// Parse Emacs mtime which may be:
+/// - A plain integer string "1779015285"
+/// - Emacs internal time "(HIGH LOW MICROSEC PICOSEC)" → HIGH*65536 + LOW
+/// - nil / empty → 0
+fn parse_mtime(raw: Option<&str>) -> i64 {
+ let s = match raw { Some(s) if !s.is_empty() => s, _ => return 0 };
+ // Try plain integer first
+ if let Ok(n) = s.trim().parse::<i64>() { return n; }
+ // Emacs list: strip parens, split on whitespace, take HIGH and LOW
+ let inner = s.trim().trim_start_matches('(').trim_end_matches(')');
+ let parts: Vec<&str> = inner.split_whitespace().collect();
+ if parts.len() >= 2 {
+ let high: i64 = parts[0].parse().unwrap_or(0);
+ let low: i64 = parts[1].parse().unwrap_or(0);
+ return high * 65536 + low;
+ }
+ 0
+}
diff --git a/navi-core/src/emacs.rs b/navi-core/src/emacs.rs
@@ -0,0 +1,149 @@
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use crate::graph::Node;
+
+static EMACSCLIENT_CANDIDATES: &[&str] = &[
+ "emacsclient",
+ "/opt/homebrew/bin/emacsclient",
+ "/usr/local/bin/emacsclient",
+ "/usr/bin/emacsclient",
+ "/opt/local/bin/emacsclient",
+ "/Applications/Emacs.app/Contents/MacOS/bin/emacsclient",
+ "/Applications/Emacs.app/Contents/MacOS/emacsclient",
+ "/run/current-system/sw/bin/emacsclient",
+ "/snap/bin/emacsclient",
+ "~/.local/bin/emacsclient",
+ "~/.nix-profile/bin/emacsclient",
+];
+
+pub struct EmacsClient {
+ pub binary: String,
+ pub server_name: String,
+}
+
+impl EmacsClient {
+ pub fn new(cfg_binary: &str, server_name: &str) -> Self {
+ let binary = if !cfg_binary.is_empty() && Path::new(cfg_binary).exists() {
+ cfg_binary.to_string()
+ } else {
+ detect_emacsclient()
+ };
+ EmacsClient { binary, server_name: server_name.to_string() }
+ }
+
+ pub fn open_node(&self, node: &Node) -> Result<(), String> {
+ if node.file.is_empty() || !Path::new(&node.file).exists() {
+ return Err(format!("File not found: {}", node.file));
+ }
+
+ let sock = find_emacs_socket(&self.server_name);
+ let mut cmd_args: Vec<String> = Vec::new();
+ if let Some(s) = &sock {
+ cmd_args.push("--socket-name".into());
+ cmd_args.push(s.clone());
+ }
+ cmd_args.push("--no-wait".into());
+ cmd_args.push("--alternate-editor=".into());
+ cmd_args.push("--eval".into());
+
+ let path = node.file.replace('\\', "\\\\").replace('"', "\\\"");
+ let goto = if node.level > 0 && node.pos > 0 {
+ format!(" (goto-char {})", node.pos)
+ } else {
+ String::new()
+ };
+ let elisp = format!(
+ "(progn (find-file \"{path}\"){goto} (delete-other-windows) (when (display-graphic-p) (raise-frame)))"
+ );
+ cmd_args.push(elisp);
+
+ Command::new(&self.binary)
+ .args(&cmd_args)
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map(|_| ())
+ .map_err(|e| format!("emacsclient failed: {e}"))
+ }
+}
+
+fn detect_emacsclient() -> String {
+ for cand in EMACSCLIENT_CANDIDATES {
+ let expanded = if let Some(rest) = cand.strip_prefix("~/") {
+ format!("{}/{}", dirs::home_dir().unwrap_or_default().display(), rest)
+ } else {
+ cand.to_string()
+ };
+ if Path::new(&expanded).exists() {
+ return expanded;
+ }
+ // Try which
+ if let Ok(out) = Command::new("which").arg(cand).output() {
+ let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ if !s.is_empty() && Path::new(&s).exists() {
+ return s;
+ }
+ }
+ }
+ "emacsclient".to_string()
+}
+
+fn find_emacs_socket(server_name: &str) -> Option<String> {
+ for key in &["EMACS_SERVER_SOCKET", "EMACS_SERVER_FILE"] {
+ if let Ok(v) = std::env::var(key) {
+ if Path::new(&v).exists() {
+ return Some(v);
+ }
+ }
+ }
+
+ let uid = unsafe { libc_getuid() };
+ let names: Vec<&str> = if server_name != "server" {
+ vec![server_name, "server"]
+ } else {
+ vec!["server"]
+ };
+
+ // XDG_RUNTIME_DIR (Linux)
+ if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
+ for name in &names {
+ let p = format!("{}/emacs/{}", xdg, name);
+ if Path::new(&p).exists() { return Some(p); }
+ }
+ }
+
+ // macOS temp dirs
+ let bases: Vec<PathBuf> = {
+ let mut b = Vec::new();
+ for key in &["TMPDIR", "TMP", "TEMP"] {
+ if let Ok(v) = std::env::var(key) {
+ b.push(PathBuf::from(v));
+ }
+ }
+ b.push(PathBuf::from("/tmp"));
+ b.push(PathBuf::from("/private/tmp"));
+ b
+ };
+
+ for base in bases {
+ for name in &names {
+ let p = base.join(format!("emacs{}", uid)).join(name);
+ if p.exists() { return Some(p.to_string_lossy().into_owned()); }
+ }
+ }
+ None
+}
+
+#[cfg(unix)]
+fn libc_getuid() -> u32 {
+ unsafe { libc_uid() }
+}
+
+#[cfg(not(unix))]
+fn libc_getuid() -> u32 { 0 }
+
+#[cfg(unix)]
+extern "C" { fn getuid() -> u32; }
+
+#[cfg(unix)]
+unsafe fn libc_uid() -> u32 { getuid() }
diff --git a/navi-core/src/graph.rs b/navi-core/src/graph.rs
@@ -0,0 +1,845 @@
+use std::collections::{HashMap, HashSet};
+use rayon::prelude::*;
+use crate::db::RawNode;
+
+// ── physics constants ─────────────────────────────────────────────────────────
+pub const REPULSION: f64 = 16000.0;
+pub const SPRING_K: f64 = 0.055;
+pub const SPRING_L: f64 = 110.0;
+pub const GRAVITY: f64 = 0.020;
+pub const DAMPING: f64 = 0.90;
+pub const BASE_DT: f64 = 1.0 / 60.0;
+pub const DT_CAP: f64 = 0.05;
+pub const R_MIN: f32 = 5.0;
+pub const R_MAX: f32 = 22.0;
+
+const LAYOUT_DUR: f32 = 1.3; // seconds for layout transition animation
+
+#[derive(Debug, Clone)]
+pub struct Node {
+ pub id: String,
+ pub title: String,
+ pub file: String,
+ pub level: i64,
+ pub pos: i64,
+ pub mtime: i64,
+ pub aliases: Vec<String>,
+ pub tags: Vec<String>,
+ pub x: f64, pub y: f64,
+ pub vx: f64, pub vy: f64,
+ pub pinned: bool,
+ pub degree: usize,
+ pub radius: f32,
+}
+
+struct LayoutAnim {
+ from: Vec<(f64, f64)>,
+ to: Vec<(f64, f64)>,
+ t: f32,
+ resume_physics: bool, // true = restart force-directed after landing; false = breathing mode
+}
+
+pub struct Graph {
+ pub nodes: HashMap<String, usize>,
+ pub node_list: Vec<Node>,
+ pub edges: Vec<(usize, usize)>,
+ pub adj: HashMap<String, HashSet<String>>,
+ pub tag_colors: HashMap<String, [u8; 3]>,
+ pub physics_on: bool,
+ pub step_count: u64,
+ layout_anim: Option<LayoutAnim>,
+ physics_ease: f32, // 0 → 1 ramp after layout animation so forces fade in, not snap
+ settle_frames: u32, // consecutive frames below velocity threshold; auto-pauses at limit
+}
+
+impl Graph {
+ pub fn new(raw_nodes: Vec<RawNode>, raw_edges: Vec<(String, String)>) -> Self {
+ let n = raw_nodes.len();
+ let mut nodes: HashMap<String, usize> = HashMap::with_capacity(n);
+ let mut node_list: Vec<Node> = Vec::with_capacity(n);
+
+ for (i, rn) in raw_nodes.iter().enumerate() {
+ let node = Node {
+ id: rn.id.clone(),
+ title: if rn.title.is_empty() { rn.id[..rn.id.len().min(12)].to_string() } else { rn.title.clone() },
+ file: rn.file.clone(),
+ level: rn.level,
+ pos: rn.pos,
+ mtime: rn.mtime,
+ aliases: rn.aliases.clone(),
+ tags: rn.tags.clone(),
+ x: 0.0, y: 0.0,
+ vx: lcg_uniform(i as u64 * 2 + 3) * 4.0 - 2.0,
+ vy: lcg_uniform(i as u64 * 2 + 4) * 4.0 - 2.0,
+ pinned: false, degree: 0, radius: R_MIN,
+ };
+ nodes.insert(rn.id.clone(), i);
+ node_list.push(node);
+ }
+
+ let mut edges: Vec<(usize, usize)> = Vec::new();
+ let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
+ for nid in node_list.iter().map(|n| n.id.clone()) { adj.insert(nid, HashSet::new()); }
+ for (src, dst) in &raw_edges {
+ if let (Some(&si), Some(&di)) = (nodes.get(src), nodes.get(dst)) {
+ edges.push((si, di));
+ node_list[si].degree += 1;
+ node_list[di].degree += 1;
+ adj.entry(src.clone()).or_default().insert(dst.clone());
+ adj.entry(dst.clone()).or_default().insert(src.clone());
+ }
+ }
+
+ let max_deg = node_list.iter().map(|n| n.degree).max().unwrap_or(1).max(1);
+ let log_span = (max_deg as f32 + 1.0).ln_1p();
+ for nd in node_list.iter_mut() {
+ nd.radius = R_MIN + (R_MAX - R_MIN) * (nd.degree as f32 + 1.0).ln_1p() / log_span;
+ }
+
+ // Initial placement: disk (phyllotaxis) so physics settles into a
+ // natural filled-circle shape like Obsidian's graph view.
+ let positions = positions_disk(&node_list, &edges, n);
+ for (i, (x, y)) in positions.iter().enumerate() {
+ node_list[i].x = *x;
+ node_list[i].y = *y;
+ }
+
+ let mut all_tags: Vec<String> = node_list.iter().flat_map(|n| n.tags.iter().cloned()).collect();
+ all_tags.sort(); all_tags.dedup();
+ let phi = 0.618033988749895f64;
+ let mut tag_colors: HashMap<String, [u8; 3]> = HashMap::new();
+ for (i, tag) in all_tags.iter().enumerate() {
+ let h = (i as f64 * phi) % 1.0;
+ tag_colors.insert(tag.clone(), hsv_to_rgb(h, 0.62, 0.88));
+ }
+
+ Graph { nodes, node_list, edges, adj, tag_colors,
+ physics_on: true, step_count: 0, layout_anim: None, physics_ease: 1.0,
+ settle_frames: 0 }
+ }
+
+ // ── Layout transitions ────────────────────────────────────────────────────
+
+ pub fn positions_radial(&self) -> Vec<(f64, f64)> {
+ positions_radial(&self.node_list, &self.edges, self.node_list.len())
+ }
+
+ pub fn positions_ring(&self) -> Vec<(f64, f64)> {
+ positions_ring(&self.node_list, &self.edges, self.node_list.len())
+ }
+
+ pub fn positions_disk(&self) -> Vec<(f64, f64)> {
+ positions_disk(&self.node_list, &self.edges, self.node_list.len())
+ }
+
+ /// Column — BFS shells as vertical strips (L→R), nodes spread vertically.
+ pub fn positions_column(&self) -> Vec<(f64, f64)> {
+ positions_layered(&self.node_list, &self.edges, self.node_list.len(), true)
+ }
+
+ /// Row — BFS shells as horizontal strips (T→B), nodes spread horizontally.
+ pub fn positions_row(&self) -> Vec<(f64, f64)> {
+ positions_layered(&self.node_list, &self.edges, self.node_list.len(), false)
+ }
+
+ /// Top-down tree — hub at top, each BFS shell becomes a horizontal row.
+ /// Nodes within each row sorted by parent position to reduce crossings.
+ pub fn positions_tree(&self) -> Vec<(f64, f64)> {
+ positions_tree(&self.node_list, &self.edges, self.node_list.len())
+ }
+
+ /// Begin a smooth transition to `targets`. Nodes float to their new
+ /// positions over LAYOUT_DUR seconds; physics resumes after.
+ pub fn begin_layout_transition(&mut self, targets: Vec<(f64, f64)>, resume_physics: bool) {
+ let from = self.node_list.iter().map(|n| (n.x, n.y)).collect();
+ self.layout_anim = Some(LayoutAnim { from, to: targets, t: 0.0, resume_physics });
+ self.physics_on = true;
+ }
+
+ pub fn is_layout_animating(&self) -> bool { self.layout_anim.is_some() }
+
+ // ── Step ─────────────────────────────────────────────────────────────────
+
+ /// Resume full force-directed physics from the current node positions,
+ /// easing forces in so nodes don't snap from their current layout.
+ pub fn resume_physics(&mut self) {
+ self.physics_on = true;
+ self.physics_ease = 0.0;
+ }
+
+ pub fn step(&mut self, dt: f64) {
+ let dt = dt.min(DT_CAP);
+ let n = self.node_list.len();
+ if n == 0 { return; }
+
+ // Layout animation: smoothstep lerp, no physics
+ if let Some(ref mut anim) = self.layout_anim {
+ self.physics_on = true; // keep step() ticking
+ anim.t = (anim.t + dt as f32 / LAYOUT_DUR).min(1.0);
+ let t = anim.t;
+ let ease = (t * t * (3.0 - 2.0 * t)) as f64;
+ for i in 0..n {
+ let nd = &mut self.node_list[i];
+ nd.x = anim.from[i].0 + (anim.to[i].0 - anim.from[i].0) * ease;
+ nd.y = anim.from[i].1 + (anim.to[i].1 - anim.from[i].1) * ease;
+ nd.vx = 0.0; nd.vy = 0.0;
+ }
+ if anim.t >= 1.0 {
+ let resume = anim.resume_physics;
+ self.layout_anim = None;
+ if resume {
+ self.resume_physics(); // ease forces back in so nodes settle naturally
+ } else {
+ self.physics_on = false; // breathing mode — holds layout positions
+ }
+ }
+ return;
+ }
+
+ if !self.physics_on {
+ // Breathing is handled purely visually in the painter — no position mutation here.
+ self.step_count = self.step_count.wrapping_add(1);
+ return;
+ }
+
+ // Full force-directed physics — ease forces in after a layout to avoid snapping
+ if self.physics_ease < 1.0 {
+ self.physics_ease = (self.physics_ease + dt as f32 / 3.5).min(1.0);
+ }
+ let ease = self.physics_ease as f64;
+
+ let mut fx = vec![0.0f64; n];
+ let mut fy = vec![0.0f64; n];
+
+ {
+ let pts: Vec<(f64, f64)> = self.node_list.iter().map(|nd| (nd.x, nd.y)).collect();
+ let bh = bh_build(&pts);
+ // Each node queries the read-only tree independently — embarrassingly parallel.
+ let rep_forces: Vec<(f64, f64)> = pts.par_iter().enumerate()
+ .map_with(Vec::with_capacity(64), |stk, (i, &(x, y))| bh_force(&bh, x, y, i, stk))
+ .collect();
+ for (i, (rfx, rfy)) in rep_forces.into_iter().enumerate() {
+ fx[i] += rfx; fy[i] += rfy;
+ }
+ }
+
+ for &(si, di) in &self.edges {
+ let dx = self.node_list[di].x - self.node_list[si].x;
+ let dy = self.node_list[di].y - self.node_list[si].y;
+ let dist = dx.hypot(dy).max(1e-4);
+ let f = SPRING_K * (dist - SPRING_L) / dist;
+ fx[si] += dx * f; fy[si] += dy * f;
+ fx[di] -= dx * f; fy[di] -= dy * f;
+ }
+
+ self.step_count = self.step_count.wrapping_add(1);
+ let t = self.step_count as f64 * 0.006;
+ const PHI: f64 = 1.618033988749895;
+ for i in 0..n {
+ let phase = i as f64 * PHI;
+ fx[i] += (t + phase).sin() * 0.35;
+ fy[i] += (t * 1.272 + phase * 0.849).cos() * 0.35;
+ }
+
+ // Scale ALL forces (including gravity) by ease so nothing snaps after layout animation
+ let g = GRAVITY * ease;
+ for i in 0..n { fx[i] *= ease; fy[i] *= ease; }
+
+ let damp = DAMPING.powf(dt / BASE_DT);
+ let max_r = (n as f64 * 15.0).max(350.0);
+ const MAX_V: f64 = 650.0;
+ for i in 0..n {
+ let nd = &mut self.node_list[i];
+ if nd.pinned { nd.vx = 0.0; nd.vy = 0.0; continue; }
+ nd.vx = (nd.vx + (fx[i] - nd.x * g) * dt) * damp;
+ nd.vy = (nd.vy + (fy[i] - nd.y * g) * dt) * damp;
+ // Cap velocity before position update so no single step can escape
+ let spd = nd.vx.hypot(nd.vy);
+ if spd > MAX_V { nd.vx = nd.vx / spd * MAX_V; nd.vy = nd.vy / spd * MAX_V; }
+ nd.x += nd.vx * dt;
+ nd.y += nd.vy * dt;
+ // Hard radial clamp — any node that slips past loses its velocity
+ let d = nd.x.hypot(nd.y);
+ if d > max_r {
+ let s = max_r / d;
+ nd.x *= s; nd.y *= s;
+ nd.vx *= 0.1; nd.vy *= 0.1;
+ }
+ }
+
+ // Auto-pause: if all nodes are near-still for ~1.5 s, switch to breathing mode
+ let max_v = self.node_list.iter().map(|nd| nd.vx.hypot(nd.vy)).fold(0.0f64, f64::max);
+ if max_v < 1.5 && self.physics_ease >= 1.0 {
+ self.settle_frames += 1;
+ if self.settle_frames > 90 { self.physics_on = false; self.settle_frames = 0; }
+ } else {
+ self.settle_frames = 0;
+ }
+ }
+
+ pub fn bfs(&self, start_id: &str, hops: usize) -> HashSet<String> {
+ let mut visited: HashSet<String> = HashSet::new();
+ visited.insert(start_id.to_string());
+ let mut frontier = visited.clone();
+ for _ in 0..hops {
+ let mut nxt = HashSet::new();
+ for nid in &frontier {
+ for nb in self.adj.get(nid).into_iter().flatten() {
+ if visited.insert(nb.clone()) { nxt.insert(nb.clone()); }
+ }
+ }
+ frontier = nxt;
+ }
+ visited
+ }
+
+ pub fn transplant_positions(&mut self, old: &Graph) {
+ for nd in &mut self.node_list {
+ if let Some(&oi) = old.nodes.get(&nd.id) {
+ let on = &old.node_list[oi];
+ nd.x = on.x; nd.y = on.y;
+ nd.vx = on.vx; nd.vy = on.vy;
+ }
+ }
+ }
+}
+
+// ── Layout position calculators ───────────────────────────────────────────────
+
+/// Radial shell layout: hub at origin, BFS shells as concentric rings.
+/// Nodes within each ring sorted by parent angle to minimise crossings.
+fn positions_radial(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> {
+ if n == 0 { return Vec::new(); }
+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for &(s, d) in edges { adj[s].push(d); adj[d].push(s); }
+
+ let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0);
+ let unvisited = n + 1;
+ let mut shell = vec![unvisited; n];
+ shell[hub] = 0;
+ let mut shells: Vec<Vec<usize>> = vec![vec![hub]];
+ let mut frontier = vec![hub];
+
+ while !frontier.is_empty() {
+ let sh = shells.len();
+ let mut next = Vec::new();
+ let mut sh_nodes = Vec::new();
+ for &cur in &frontier {
+ for &nb in &adj[cur] {
+ if shell[nb] == unvisited {
+ shell[nb] = sh; sh_nodes.push(nb); next.push(nb);
+ }
+ }
+ }
+ if !sh_nodes.is_empty() { shells.push(sh_nodes); }
+ frontier = next;
+ }
+
+ // Disconnected nodes — collected but NOT added to shells.
+ // They get their own compact cluster near the hub so gravity keeps them stable.
+ let disc: Vec<usize> = (0..n).filter(|&i| shell[i] == unvisited).collect();
+
+ let n_shells = shells.len().saturating_sub(1).max(1);
+ let ring_gap = SPRING_L.max(80.0 + n as f64 * 4.0 / n_shells as f64);
+
+ let mut positions = vec![(0.0_f64, 0.0_f64); n];
+ // hub at origin
+ positions[hub] = (0.0, 0.0);
+
+ for (sh_idx, sh_nodes) in shells.iter().enumerate() {
+ if sh_idx == 0 { continue; }
+ let r = sh_idx as f64 * ring_gap;
+ let count = sh_nodes.len();
+
+ let mut sorted: Vec<(usize, f64)> = sh_nodes.iter().map(|&ni| {
+ let pa = adj[ni].iter()
+ .filter(|&&nb| shell[nb] < sh_idx)
+ .map(|&nb| positions[nb].1.atan2(positions[nb].0))
+ .next().unwrap_or(0.0);
+ (ni, pa)
+ }).collect();
+ sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+
+ for (pos, &(ni, _)) in sorted.iter().enumerate() {
+ let angle = std::f64::consts::TAU * pos as f64 / count as f64;
+ let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(1)) * 12.0 - 6.0;
+ let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(2)) * 12.0 - 6.0;
+ positions[ni] = (r * angle.cos() + jx, r * angle.sin() + jy);
+ }
+ }
+
+ // Orphans: compact cluster at gravity equilibrium (r ≈ perturbation/GRAVITY ≈ 25wu).
+ // Placed in the bottom-left quadrant so they're visually distinct from
+ // connected rings and never on the same radial lines.
+ if !disc.is_empty() {
+ let orphan_r = 28.0_f64;
+ let arc_start = std::f64::consts::PI * 1.15;
+ let arc_spread = std::f64::consts::PI * 0.45;
+ let dcount = disc.len();
+ for (i, &ni) in disc.iter().enumerate() {
+ let t = if dcount == 1 { 0.5 } else { i as f64 / (dcount - 1) as f64 };
+ let angle = arc_start + arc_spread * t;
+ let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(7)) * 8.0 - 4.0;
+ let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(8)) * 8.0 - 4.0;
+ positions[ni] = (orphan_r * angle.cos() + jx, orphan_r * angle.sin() + jy);
+ }
+ }
+
+ positions
+}
+
+/// Ring layout: all nodes on a single circle, BFS-ordered from hub.
+fn positions_ring(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> {
+ if n == 0 { return Vec::new(); }
+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for &(s, d) in edges { adj[s].push(d); adj[d].push(s); }
+
+ let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0);
+ let mut order = Vec::with_capacity(n);
+ let mut visited = vec![false; n];
+ let mut queue = std::collections::VecDeque::new();
+ queue.push_back(hub); visited[hub] = true;
+ while let Some(cur) = queue.pop_front() {
+ order.push(cur);
+ let mut nbs: Vec<usize> = adj[cur].iter().copied().filter(|&nb| !visited[nb]).collect();
+ nbs.sort_by_key(|&nb| std::cmp::Reverse(node_list[nb].degree));
+ for nb in nbs { visited[nb] = true; queue.push_back(nb); }
+ }
+ for i in 0..n { if !visited[i] { order.push(i); } }
+
+ let r = (60.0_f64).max((380.0_f64).min(55.0 + n as f64 * 2.8));
+ let mut positions = vec![(0.0_f64, 0.0_f64); n];
+ for (pos, &ni) in order.iter().enumerate() {
+ let angle = std::f64::consts::TAU * pos as f64 / n as f64;
+ let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(1)) * 12.0 - 6.0;
+ let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(2)) * 12.0 - 6.0;
+ positions[ni] = (r * angle.cos() + jx, r * angle.sin() + jy);
+ }
+ positions
+}
+
+/// Disk layout: shell-based placement at SPRING_L ring gap.
+/// Nodes start at their physics equilibrium distance so physics barely needs
+/// to move them — edges never cross on spawn because each shell's nodes are
+/// angle-sorted by their parent's position. Physics then relaxes the layout
+/// into a natural filled circle (Obsidian-style).
+fn positions_disk(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> {
+ if n == 0 { return Vec::new(); }
+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for &(s, d) in edges { adj[s].push(d); adj[d].push(s); }
+
+ let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0);
+ let unvisited = n + 1;
+ let mut shell = vec![unvisited; n];
+ shell[hub] = 0;
+ let mut shells: Vec<Vec<usize>> = vec![vec![hub]];
+ let mut frontier = vec![hub];
+ while !frontier.is_empty() {
+ let sh = shells.len();
+ let mut next = Vec::new(); let mut sh_nodes = Vec::new();
+ for &cur in &frontier {
+ for &nb in &adj[cur] {
+ if shell[nb] == unvisited { shell[nb] = sh; sh_nodes.push(nb); next.push(nb); }
+ }
+ }
+ if !sh_nodes.is_empty() { shells.push(sh_nodes); }
+ frontier = next;
+ }
+ let disc: Vec<usize> = (0..n).filter(|&i| shell[i] == unvisited).collect();
+
+ // Ring gap = SPRING_L so connected nodes begin exactly at their rest length
+ let ring_gap = SPRING_L;
+
+ let mut positions = vec![(0.0_f64, 0.0_f64); n];
+ positions[hub] = (0.0, 0.0);
+
+ for (sh_idx, sh_nodes) in shells.iter().enumerate() {
+ if sh_idx == 0 { continue; }
+ let r = sh_idx as f64 * ring_gap;
+ let count = sh_nodes.len();
+ // Sort by parent angle → no crossings between adjacent shells
+ let mut sorted: Vec<(usize, f64)> = sh_nodes.iter().map(|&ni| {
+ let pa = adj[ni].iter()
+ .filter(|&&nb| shell[nb] < sh_idx)
+ .map(|&nb| positions[nb].1.atan2(positions[nb].0))
+ .next().unwrap_or(0.0);
+ (ni, pa)
+ }).collect();
+ sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+ for (pos, &(ni, _)) in sorted.iter().enumerate() {
+ let angle = std::f64::consts::TAU * pos as f64 / count as f64;
+ let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(1)) * 8.0 - 4.0;
+ let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(2)) * 8.0 - 4.0;
+ positions[ni] = (r * angle.cos() + jx, r * angle.sin() + jy);
+ }
+ }
+
+ // Orphans near origin — at gravity equilibrium, won't drift
+ if !disc.is_empty() {
+ let orphan_r = 28.0_f64;
+ let arc_start = std::f64::consts::PI * 1.15;
+ let arc_span = std::f64::consts::PI * 0.45;
+ let dc = disc.len();
+ for (i, &ni) in disc.iter().enumerate() {
+ let t = if dc == 1 { 0.5 } else { i as f64 / (dc - 1) as f64 };
+ let angle = arc_start + arc_span * t;
+ let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(7)) * 8.0 - 4.0;
+ let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(8)) * 8.0 - 4.0;
+ positions[ni] = (orphan_r * angle.cos() + jx, orphan_r * angle.sin() + jy);
+ }
+ }
+ prerelax(positions, edges, n)
+}
+
+/// Generic layered layout used by both Column and Row.
+/// `column_mode=true` → connected shells advance left→right, orphans placed
+/// in a separate cluster BELOW the connected graph.
+/// `column_mode=false` → connected shells advance top→bottom, orphans placed
+/// in a separate cluster to the RIGHT of the connected graph.
+/// Orphans are NEVER placed inline with connected nodes so they cannot be
+/// mistaken for part of a chain.
+fn positions_layered(node_list: &[Node], edges: &[(usize, usize)], n: usize, column_mode: bool) -> Vec<(f64, f64)> {
+ if n == 0 { return Vec::new(); }
+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for &(s, d) in edges { adj[s].push(d); adj[d].push(s); }
+
+ let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0);
+ let unvisited = n + 1;
+ let mut shell = vec![unvisited; n];
+ shell[hub] = 0;
+ let mut shells: Vec<Vec<usize>> = vec![vec![hub]];
+ let mut frontier = vec![hub];
+ while !frontier.is_empty() {
+ let sh = shells.len();
+ let mut next = Vec::new(); let mut sh_nodes = Vec::new();
+ for &cur in &frontier {
+ for &nb in &adj[cur] {
+ if shell[nb] == unvisited { shell[nb] = sh; sh_nodes.push(nb); next.push(nb); }
+ }
+ }
+ if !sh_nodes.is_empty() { shells.push(sh_nodes); }
+ frontier = next;
+ }
+ // Collect disconnected nodes — kept completely separate, never added to shells
+ let orphans: Vec<usize> = (0..n).filter(|&i| shell[i] == unvisited).collect();
+
+ // main_gap ≈ SPRING_L so physics equilibrium matches the layout.
+ // cross_gap larger so labels never crowd each other.
+ let main_gap = SPRING_L * 1.1;
+ let cross_gap = SPRING_L * 1.8;
+ let n_shells = shells.len();
+ let total_main = (n_shells as f64 - 1.0) * main_gap;
+ // Furthest extent of connected graph on the main axis
+ let connected_main_max = total_main / 2.0;
+
+ let mut positions = vec![(0.0_f64, 0.0_f64); n];
+
+ // Place connected shells
+ for (sh_idx, sh_nodes) in shells.iter().enumerate() {
+ let main = sh_idx as f64 * main_gap - total_main / 2.0;
+ let count = sh_nodes.len();
+ let total_cross = (count as f64 - 1.0) * cross_gap;
+
+ // Sort by parent's cross-axis position so edges don't cross between shells
+ let mut sorted: Vec<(usize, f64)> = sh_nodes.iter().map(|&ni| {
+ let cp = adj[ni].iter()
+ .filter(|&&nb| shell[nb] < sh_idx)
+ .map(|&nb| if column_mode { positions[nb].1 } else { positions[nb].0 })
+ .next().unwrap_or(0.0);
+ (ni, cp)
+ }).collect();
+ sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+
+ for (pos, &(ni, _)) in sorted.iter().enumerate() {
+ let cross = pos as f64 * cross_gap - total_cross / 2.0;
+ positions[ni] = if column_mode { (main, cross) } else { (cross, main) };
+ }
+ }
+
+ // Orphans always go to the RIGHT of the entire connected graph, spread in y.
+ // This guarantees no drawn edge (which stays within the connected x-range)
+ // can pass through an orphan node regardless of layout mode.
+ if !orphans.is_empty() {
+ let max_conn_x = shells.iter().flatten()
+ .map(|&i| positions[i].0)
+ .fold(0.0_f64, f64::max);
+ // Cluster orphans in a phyllotaxis spiral to the right — no straight-line
+ // alignment possible so they can never look like they sit on a connection.
+ let orphan_cx = max_conn_x + main_gap * 1.8;
+ let cluster_r = cross_gap * (orphans.len() as f64).sqrt().max(1.0) * 0.55;
+ let golden = std::f64::consts::TAU * (1.0 - 1.0 / 1.618033988749895);
+ for (i, &ni) in orphans.iter().enumerate() {
+ let r = (i as f64 / orphans.len().max(1) as f64).sqrt() * cluster_r;
+ let theta = i as f64 * golden;
+ positions[ni] = (orphan_cx + r * theta.cos(), r * theta.sin());
+ }
+ }
+
+ positions
+}
+
+/// Proper top-down tree using subtree-width allocation (simplified Reingold–Tilford).
+/// Each subtree occupies a non-overlapping horizontal band, so no edges can cross.
+fn positions_tree(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> {
+ if n == 0 { return Vec::new(); }
+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for &(s, d) in edges { adj[s].push(d); adj[d].push(s); }
+
+ let root = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0);
+
+ // Build spanning tree via BFS; each node gets exactly one parent
+ let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
+ let mut depth = vec![0usize; n];
+ let mut bfs_ord = Vec::with_capacity(n);
+ let mut visited = vec![false; n];
+ let mut queue = std::collections::VecDeque::new();
+ queue.push_back(root); visited[root] = true;
+ while let Some(cur) = queue.pop_front() {
+ bfs_ord.push(cur);
+ let mut nbs: Vec<usize> = adj[cur].iter().copied().filter(|&nb| !visited[nb]).collect();
+ nbs.sort_by_key(|&nb| std::cmp::Reverse(node_list[nb].degree));
+ for nb in nbs {
+ visited[nb] = true;
+ children[cur].push(nb);
+ depth[nb] = depth[cur] + 1;
+ queue.push_back(nb);
+ }
+ }
+ let mut disconnected: Vec<usize> = (0..n).filter(|&i| !visited[i]).collect();
+
+ let unit = SPRING_L * 1.8; // wide enough for labels; ~matches cross-axis rest length
+ let row_gap = SPRING_L * 1.1;
+
+ // Bottom-up: each leaf = width 1, each internal node = sum of children widths
+ let mut width = vec![1.0_f64; n];
+ for &ni in bfs_ord.iter().rev() {
+ if !children[ni].is_empty() {
+ width[ni] = children[ni].iter().map(|&c| width[c]).sum();
+ }
+ }
+
+ let max_depth = depth.iter().copied().max().unwrap_or(0);
+ let total_h = max_depth as f64 * row_gap;
+
+ let mut positions = vec![(0.0_f64, 0.0_f64); n];
+ let mut left_edge = vec![0.0_f64; n];
+ left_edge[root] = -(width[root] * unit) / 2.0;
+
+ // Top-down: place each node at the centre of its subtree's horizontal band
+ for &ni in &bfs_ord {
+ let x = left_edge[ni] + width[ni] * unit / 2.0;
+ let y = depth[ni] as f64 * row_gap - total_h / 2.0;
+ positions[ni] = (x, y);
+
+ // Distribute children left-to-right within this node's band
+ let mut cursor = left_edge[ni];
+ for &ci in &children[ni] {
+ left_edge[ci] = cursor;
+ cursor += width[ci] * unit;
+ }
+ }
+
+ // Disconnected nodes go to the RIGHT of the tree, spread in y.
+ // Placing them below would put them in the path of the tree's vertical edges.
+ if !disconnected.is_empty() {
+ let max_tree_x = bfs_ord.iter()
+ .map(|&i| positions[i].0)
+ .fold(0.0_f64, f64::max);
+ let orphan_cx = max_tree_x + unit * 1.8;
+ let cluster_r = unit * (disconnected.len() as f64).sqrt().max(1.0) * 0.55;
+ let golden = std::f64::consts::TAU * (1.0 - 1.0 / 1.618033988749895);
+ for (i, &ni) in disconnected.iter().enumerate() {
+ let r = (i as f64 / disconnected.len().max(1) as f64).sqrt() * cluster_r;
+ let theta = i as f64 * golden;
+ positions[ni] = (orphan_cx + r * theta.cos(), r * theta.sin());
+ }
+ }
+ positions
+}
+
+/// Run simplified physics on `pos` until near-equilibrium, then return the
+/// settled coordinates. Operates on a pure copy — the Graph is not mutated.
+// ── Barnes-Hut O(n log n) repulsion ──────────────────────────────────────────
+
+const BH_THETA: f64 = 0.9; // opening angle; lower = more accurate, higher = faster
+
+struct BHCell {
+ x0: f64, y0: f64, x1: f64, y1: f64, // bounds
+ mass: f64,
+ cx: f64, cy: f64, // center of mass
+ ch: [u32; 4], // children; u32::MAX = absent
+ body: i32, // ≥0 leaf idx, -1 internal, -2 empty
+}
+
+impl BHCell {
+ fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self {
+ Self { x0, y0, x1, y1, mass: 0.0, cx: 0.0, cy: 0.0, ch: [u32::MAX; 4], body: -2 }
+ }
+ fn quad(&self, x: f64, y: f64) -> usize {
+ let mx = (self.x0 + self.x1) * 0.5;
+ let my = (self.y0 + self.y1) * 0.5;
+ (x >= mx) as usize | (((y >= my) as usize) << 1)
+ }
+ fn child_bounds(&self, q: usize) -> (f64, f64, f64, f64) {
+ let mx = (self.x0 + self.x1) * 0.5;
+ let my = (self.y0 + self.y1) * 0.5;
+ match q {
+ 0 => (self.x0, self.y0, mx, my ),
+ 1 => (mx, self.y0, self.x1, my ),
+ 2 => (self.x0, my, mx, self.y1 ),
+ _ => (mx, my, self.x1, self.y1 ),
+ }
+ }
+}
+
+fn bh_insert(pool: &mut Vec<BHCell>, start: usize, bx: f64, by: f64, bi: usize) {
+ let mut ci = start;
+ loop {
+ match pool[ci].body {
+ -2 => {
+ pool[ci].body = bi as i32;
+ pool[ci].mass = 1.0;
+ pool[ci].cx = bx;
+ pool[ci].cy = by;
+ return;
+ }
+ -1 => {
+ let m = pool[ci].mass;
+ pool[ci].cx = (pool[ci].cx * m + bx) / (m + 1.0);
+ pool[ci].cy = (pool[ci].cy * m + by) / (m + 1.0);
+ pool[ci].mass = m + 1.0;
+ let q = pool[ci].quad(bx, by);
+ let ch = pool[ci].ch[q];
+ if ch == u32::MAX {
+ let (cx0, cy0, cx1, cy1) = pool[ci].child_bounds(q);
+ let new = pool.len() as u32;
+ pool.push(BHCell::new(cx0, cy0, cx1, cy1));
+ pool[ci].ch[q] = new;
+ ci = new as usize;
+ } else {
+ ci = ch as usize;
+ }
+ }
+ _ => {
+ // Leaf → split into internal, then re-insert both bodies
+ let ob = pool[ci].body as usize;
+ let (ox, oy) = (pool[ci].cx, pool[ci].cy);
+ if (ox - bx).abs() + (oy - by).abs() < 1e-9 {
+ pool[ci].mass += 1.0; // coincident: just accumulate mass
+ return;
+ }
+ pool[ci].body = -1;
+ pool[ci].ch = [u32::MAX; 4];
+ pool[ci].mass = 0.0;
+ bh_insert(pool, ci, ox, oy, ob);
+ bh_insert(pool, ci, bx, by, bi);
+ return;
+ }
+ }
+ }
+}
+
+fn bh_force(pool: &[BHCell], bx: f64, by: f64, self_bi: usize, stack: &mut Vec<usize>) -> (f64, f64) {
+ let t2 = BH_THETA * BH_THETA;
+ stack.clear();
+ stack.push(0);
+ let (mut fx, mut fy) = (0.0f64, 0.0f64);
+ while let Some(ci) = stack.pop() {
+ let c = &pool[ci];
+ if c.body == -2 || c.body == self_bi as i32 { continue; }
+ let dx = c.cx - bx;
+ let dy = c.cy - by;
+ let d2 = dx * dx + dy * dy;
+ if d2 < 0.01 { continue; }
+ let size = (c.x1 - c.x0).max(c.y1 - c.y0);
+ if c.body >= 0 || size * size < t2 * d2 {
+ let d = d2.sqrt();
+ let f = REPULSION * c.mass / (d2 * d);
+ fx -= dx * f;
+ fy -= dy * f;
+ } else {
+ for &ch in &c.ch { if ch != u32::MAX { stack.push(ch as usize); } }
+ }
+ }
+ (fx, fy)
+}
+
+fn bh_build(pts: &[(f64, f64)]) -> Vec<BHCell> {
+ let n = pts.len();
+ if n == 0 { return vec![]; }
+ let (mut x0, mut x1, mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
+ for &(x, y) in pts {
+ x0 = x0.min(x); x1 = x1.max(x);
+ y0 = y0.min(y); y1 = y1.max(y);
+ }
+ let pad = 100.0;
+ let mut pool = Vec::with_capacity(n * 4);
+ pool.push(BHCell::new(x0 - pad, y0 - pad, x1 + pad, y1 + pad));
+ for (i, &(x, y)) in pts.iter().enumerate() { bh_insert(&mut pool, 0, x, y, i); }
+ pool
+}
+
+fn prerelax(
+ mut pos: Vec<(f64, f64)>,
+ edges: &[(usize, usize)],
+ n: usize,
+) -> Vec<(f64, f64)> {
+ if n == 0 { return pos; }
+ let steps = (2_000_000usize / (n * n).max(1)).clamp(30, 240);
+ let mut vel: Vec<(f64, f64)> = vec![(0.0, 0.0); n];
+ let dt = 1.0 / 60.0_f64;
+ let damp = DAMPING.powf(dt / BASE_DT);
+ let max_r = (n as f64 * 15.0).max(350.0);
+ const MV: f64 = 650.0;
+
+ for _ in 0..steps {
+ let mut fx = vec![0.0f64; n];
+ let mut fy = vec![0.0f64; n];
+
+ {
+ let bh = bh_build(&pos);
+ let rep_forces: Vec<(f64, f64)> = pos.par_iter().enumerate()
+ .map_with(Vec::with_capacity(64), |stk, (i, &(x, y))| bh_force(&bh, x, y, i, stk))
+ .collect();
+ for (i, (rfx, rfy)) in rep_forces.into_iter().enumerate() {
+ fx[i] += rfx; fy[i] += rfy;
+ }
+ }
+ for &(si, di) in edges {
+ let dx = pos[di].0 - pos[si].0;
+ let dy = pos[di].1 - pos[si].1;
+ let dist = dx.hypot(dy).max(1e-4);
+ let f = SPRING_K * (dist - SPRING_L) / dist;
+ fx[si] += dx * f; fy[si] += dy * f;
+ fx[di] -= dx * f; fy[di] -= dy * f;
+ }
+ for i in 0..n {
+ vel[i].0 = (vel[i].0 + (fx[i] - pos[i].0 * GRAVITY) * dt) * damp;
+ vel[i].1 = (vel[i].1 + (fy[i] - pos[i].1 * GRAVITY) * dt) * damp;
+ let spd = vel[i].0.hypot(vel[i].1);
+ if spd > MV { vel[i].0 = vel[i].0 / spd * MV; vel[i].1 = vel[i].1 / spd * MV; }
+ pos[i].0 += vel[i].0 * dt;
+ pos[i].1 += vel[i].1 * dt;
+ let d = pos[i].0.hypot(pos[i].1);
+ if d > max_r { let s = max_r / d; pos[i].0 *= s; pos[i].1 *= s; vel[i] = (0.0, 0.0); }
+ }
+ }
+ pos
+}
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+fn lcg_uniform(seed: u64) -> f64 {
+ let x = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
+ (x >> 11) as f64 / (1u64 << 53) as f64
+}
+
+fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [u8; 3] {
+ let i = (h * 6.0) as u32;
+ let f = h * 6.0 - i as f64;
+ let p = v * (1.0 - s);
+ let q = v * (1.0 - f * s);
+ let t = v * (1.0 - (1.0 - f) * s);
+ let (r, g, b) = match i % 6 { 0=>(v,t,p), 1=>(q,v,p), 2=>(p,v,t), 3=>(p,q,v), 4=>(t,p,v), _=>(v,p,q) };
+ [(r*255.0) as u8, (g*255.0) as u8, (b*255.0) as u8]
+}
diff --git a/navi-core/src/lib.rs b/navi-core/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod config;
+pub mod db;
+pub mod emacs;
+pub mod graph;
+
+pub use config::Config;
+pub use db::{load_graph, RawNode};
+pub use emacs::EmacsClient;
+pub use graph::{Graph, Node};
diff --git a/navi/Cargo.toml b/navi/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "navi"
+version = "1.0.0"
+edition = "2021"
+
+[dependencies]
+navi-core = { path = "../navi-core" }
+egui = { version = "0.29", features = ["default_fonts", "bytemuck"] }
+egui_extras = { version = "0.29", features = ["image"] }
+egui-winit = { version = "0.29", default-features = false, features = ["clipboard", "links"] }
+egui_glow = { version = "0.29", default-features = false, features = ["winit"] }
+glow = "0.14"
+glutin = "0.32"
+glutin-winit = "0.5"
+winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
+raw-window-handle = "0.6"
+regex = "1"
+
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = "0.6"
+objc2-foundation = { version = "0.3", features = ["NSRunLoop", "NSString", "NSObjCRuntime", "NSThread", "NSDate"] }
+objc2-app-kit = { version = "0.3", features = ["NSView", "NSWindow", "NSScreen"] }
+objc2-quartz-core = { version = "0.3", features = ["CADisplayLink", "CAFrameRateRange"] }
+
+[profile.release]
+opt-level = 3
+lto = true
diff --git a/navi/src/app.rs b/navi/src/app.rs
@@ -0,0 +1,955 @@
+use egui::{Color32, Key, Pos2, Rect, Sense, Stroke, Vec2, epaint::FontId};
+use navi_core::{
+ config::Config,
+ load_graph, EmacsClient, Graph, RawNode,
+};
+use std::collections::HashSet;
+use std::time::{Duration, Instant};
+
+use crate::painter::GraphPainter;
+use crate::theme::{Theme, THEMES};
+
+const ZOOM_MIN: f32 = 0.04;
+const ZOOM_MAX: f32 = 20.0;
+const ZOOM_STEP: f32 = 1.12;
+const PAN_DECAY: f32 = 0.92;
+const BASE_DT: f32 = 1.0 / 60.0;
+const STATUS_H: f32 = 30.0;
+
+
+pub struct NaviApp {
+ graph: Graph,
+ cfg: Config,
+ db_path: String,
+ emacs: EmacsClient,
+
+ pan: Vec2,
+ zoom: f32,
+ pan_vel: Vec2,
+
+ hovered: Option<String>,
+ selected: Option<String>,
+ sel_idx: i32,
+ node_ids: Vec<String>,
+
+ pan_dragging: bool,
+ pan_origin_m: Pos2,
+ pan_origin_v: Vec2,
+ drag_samples: Vec<(Instant, f32, f32)>,
+
+ node_dragging: Option<String>,
+ node_drag_origin_w: (f64, f64),
+ node_drag_origin_s: Pos2,
+ node_drag_samples: Vec<(Instant, f64, f64)>,
+
+ last_click: Option<(String, Instant)>,
+
+ hide_dailies: bool,
+ hide_orphans: bool,
+ local_hops: usize,
+ show_tags: bool,
+ show_age: bool,
+ show_fps: bool,
+ layout_mode: u8, // 0 = Disk, 1 = Column, 2 = Tree
+ theme_idx: usize,
+ popup: Option<(String, f64)>, // transient notification (theme, layout, …)
+
+ search_active: bool,
+ search_query: String,
+
+ help_anim: f32,
+ help_visible: bool,
+
+ status_msg: Option<(String, Instant, Duration)>,
+ reload_pending: Option<std::sync::mpsc::Receiver<Result<(Vec<RawNode>, Vec<(String, String)>), String>>>,
+
+ frame_times: std::collections::VecDeque<f32>,
+ last_frame: Instant,
+ last_prof_log: Option<Instant>,
+ last_update_end: Option<Instant>,
+ post_overhead: Duration,
+ now_secs: f64,
+ physics_accumulator: f64,
+
+ // Idle / focus tracking (drives the 240↔30 fps cadence in main.rs).
+ window_focused: bool,
+ last_active_at: Instant,
+ idle_grace: Duration,
+
+ // Cached graph_rect from last frame for coordinate math during input
+ graph_rect: Rect,
+}
+
+impl NaviApp {
+ pub fn new(graph: Graph, cfg: Config, db_path: String) -> Self {
+ let node_ids: Vec<String> = graph.node_list.iter().map(|n| n.id.clone()).collect();
+ let emacs = EmacsClient::new(&cfg.emacsclient, &cfg.server_name);
+ let show_fps = cfg.show_fps;
+ let mut app = NaviApp {
+ graph,
+ cfg,
+ db_path,
+ emacs,
+ pan: Vec2::ZERO,
+ zoom: 1.0,
+ pan_vel: Vec2::ZERO,
+ hovered: None,
+ selected: None,
+ sel_idx: -1,
+ node_ids,
+ pan_dragging: false,
+ pan_origin_m: Pos2::ZERO,
+ pan_origin_v: Vec2::ZERO,
+ drag_samples: Vec::new(),
+ node_dragging: None,
+ node_drag_origin_w: (0.0, 0.0),
+ node_drag_origin_s: Pos2::ZERO,
+ node_drag_samples: Vec::new(),
+ last_click: None,
+ hide_dailies: false,
+ hide_orphans: false,
+ local_hops: 0,
+ show_tags: false,
+ show_age: true,
+ show_fps,
+ layout_mode: 0,
+ theme_idx: 0,
+ popup: None,
+ search_active: false,
+ search_query: String::new(),
+ help_anim: 0.0,
+ help_visible: false,
+ status_msg: None,
+ reload_pending: None,
+ frame_times: std::collections::VecDeque::new(),
+ last_frame: Instant::now(),
+ last_prof_log: None,
+ last_update_end: None,
+ post_overhead: Duration::from_micros(500),
+ // Default: stay at full speed for 10 s after the last activity, then
+ // drop to the idle (~30 fps) tier. Override with NAVI_IDLE_GRACE_SECS.
+ window_focused: true,
+ last_active_at: Instant::now(),
+ idle_grace: Duration::from_secs_f64(
+ std::env::var("NAVI_IDLE_GRACE_SECS").ok()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(10.0),
+ ),
+ now_secs: unix_now(),
+ physics_accumulator: 0.0,
+ graph_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(1400.0, 870.0)),
+ };
+ // positions_disk() already pre-relaxes internally, so nodes are at
+ // equilibrium. Resume physics with an ease-in so nothing snaps.
+ app.graph.resume_physics();
+ app.fit_to_nodes();
+ app
+ }
+
+ fn theme(&self) -> Theme { THEMES[self.theme_idx] }
+
+ // ── coordinate math (uses cached graph_rect) ──────────────────────────────
+
+ fn w2s(&self, wx: f64, wy: f64) -> Pos2 {
+ let cx = self.graph_rect.center().x;
+ let cy = self.graph_rect.center().y;
+ Pos2::new(cx + wx as f32 * self.zoom + self.pan.x,
+ cy + wy as f32 * self.zoom + self.pan.y)
+ }
+
+ fn s2w(&self, sx: f32, sy: f32) -> (f64, f64) {
+ let cx = self.graph_rect.center().x;
+ let cy = self.graph_rect.center().y;
+ (((sx - cx - self.pan.x) / self.zoom) as f64,
+ ((sy - cy - self.pan.y) / self.zoom) as f64)
+ }
+
+ fn node_at(&self, sx: f32, sy: f32, hidden: &HashSet<String>) -> Option<String> {
+ let mut best_d = f32::INFINITY;
+ let mut best: Option<String> = None;
+ for nd in &self.graph.node_list {
+ if hidden.contains(&nd.id) { continue; }
+ let sc = self.w2s(nd.x, nd.y);
+ let d = (sc - Pos2::new(sx, sy)).length();
+ let hit_r = (nd.radius * self.zoom).max(9.0);
+ if d <= hit_r && d < best_d {
+ best_d = d;
+ best = Some(nd.id.clone());
+ }
+ }
+ best
+ }
+
+ fn fit_to_nodes(&mut self) {
+ let nds = &self.graph.node_list;
+ if nds.is_empty() { return; }
+ let xs: Vec<f32> = nds.iter().map(|n| n.x as f32).collect();
+ let ys: Vec<f32> = nds.iter().map(|n| n.y as f32).collect();
+ let min_x = xs.iter().cloned().fold(f32::INFINITY, f32::min);
+ let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
+ let min_y = ys.iter().cloned().fold(f32::INFINITY, f32::min);
+ let max_y = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
+ let pad = navi_core::graph::R_MAX * 3.0;
+ let world_w = (max_x - min_x + pad * 2.0).max(1.0);
+ let world_h = (max_y - min_y + pad * 2.0).max(1.0);
+ self.zoom = ((1400.0 / world_w).min(870.0 / world_h) * 0.70).clamp(ZOOM_MIN, ZOOM_MAX);
+ let cx = (min_x + max_x) / 2.0;
+ let cy = (min_y + max_y) / 2.0;
+ self.pan = Vec2::new(-cx * self.zoom, -cy * self.zoom);
+ }
+
+ fn build_hidden(&self) -> HashSet<String> {
+ use std::sync::OnceLock;
+ static RE: OnceLock<regex::Regex> = OnceLock::new();
+ let re = RE.get_or_init(|| regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap());
+ let daily_dirs: HashSet<&str> = ["daily","dailies","journal","journals"].iter().cloned().collect();
+
+ let mut hidden = HashSet::new();
+ for nd in &self.graph.node_list {
+ if self.hide_dailies {
+ let normalized = nd.file.replace('\\', "/");
+ let parts: Vec<&str> = normalized.split('/').collect();
+ if re.is_match(&nd.title) || parts.iter().any(|p| daily_dirs.contains(p)) {
+ hidden.insert(nd.id.clone());
+ }
+ }
+ if self.hide_orphans && nd.degree == 0 {
+ hidden.insert(nd.id.clone());
+ }
+ }
+ hidden
+ }
+
+ fn build_faded(&self, hidden: &HashSet<String>) -> HashSet<String> {
+ let mut faded = HashSet::new();
+ let search_vis: Option<HashSet<String>> = if !self.search_query.is_empty() {
+ let q = self.search_query.to_lowercase();
+ Some(self.graph.node_list.iter()
+ .filter(|nd| !hidden.contains(&nd.id) && (
+ nd.title.to_lowercase().contains(&q) ||
+ nd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
+ ))
+ .map(|nd| nd.id.clone()).collect())
+ } else { None };
+
+ let local_vis: Option<HashSet<String>> = if self.local_hops > 0 {
+ self.selected.as_ref().map(|sel| self.graph.bfs(sel, self.local_hops))
+ } else { None };
+
+ if search_vis.is_some() || local_vis.is_some() {
+ for nd in &self.graph.node_list {
+ if hidden.contains(&nd.id) { continue; }
+ let in_search = search_vis.as_ref().map_or(true, |s| s.contains(&nd.id));
+ let in_local = local_vis.as_ref().map_or(true, |s| s.contains(&nd.id));
+ if !in_search || !in_local { faded.insert(nd.id.clone()); }
+ }
+ }
+ faded
+ }
+
+ fn open_node(&mut self, nid: &str) {
+ if let Some(&idx) = self.graph.nodes.get(nid) {
+ let nd = &self.graph.node_list[idx];
+ if let Err(e) = self.emacs.open_node(nd) {
+ self.set_msg(e, Duration::from_secs(6));
+ }
+ }
+ }
+
+ fn set_msg(&mut self, msg: String, dur: Duration) {
+ self.status_msg = Some((msg, Instant::now(), dur));
+ }
+
+ fn search_commit(&mut self) {
+ let q = self.search_query.to_lowercase();
+ if q.is_empty() { self.search_query.clear(); return; }
+ let hidden = self.build_hidden();
+ let mut matches: Vec<_> = self.graph.node_list.iter()
+ .filter(|nd| !hidden.contains(&nd.id) && (
+ nd.title.to_lowercase().contains(&q) ||
+ nd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
+ ))
+ .map(|nd| (nd.id.clone(), nd.title.to_lowercase().starts_with(&q), nd.x, nd.y))
+ .collect();
+ matches.sort_by_key(|(_, starts, _, _)| !starts);
+ if let Some((nid, _, nx, ny)) = matches.first() {
+ let nid = nid.clone();
+ self.selected = Some(nid.clone());
+ self.sel_idx = self.node_ids.iter().position(|id| id == &nid).map(|i| i as i32).unwrap_or(-1);
+ self.pan = Vec2::new(-(*nx as f32) * self.zoom, -(*ny as f32) * self.zoom);
+ }
+ self.search_query.clear();
+ }
+
+ fn cycle_layout(&mut self) {
+ self.layout_mode = (self.layout_mode + 1) % 3;
+ let (targets, name) = match self.layout_mode {
+ 1 => (self.graph.positions_column(), "Column"),
+ 2 => (self.graph.positions_tree(), "Tree"),
+ _ => (self.graph.positions_disk(), "Disk"),
+ };
+ self.popup = Some((name.to_string(), self.now_secs));
+ self.fit_to_positions(&targets);
+ let resume = self.layout_mode == 0; // Disk: resume physics so nodes spread naturally
+ self.graph.begin_layout_transition(targets, resume);
+ }
+
+ fn fit_to_positions(&mut self, positions: &[(f64, f64)]) {
+ if positions.is_empty() { return; }
+ let xs: Vec<f32> = positions.iter().map(|(x, _)| *x as f32).collect();
+ let ys: Vec<f32> = positions.iter().map(|(_, y)| *y as f32).collect();
+ let min_x = xs.iter().cloned().fold(f32::INFINITY, f32::min);
+ let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
+ let min_y = ys.iter().cloned().fold(f32::INFINITY, f32::min);
+ let max_y = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
+ let pad = navi_core::graph::R_MAX * 4.0;
+ let world_w = (max_x - min_x + pad * 2.0).max(1.0);
+ let world_h = (max_y - min_y + pad * 2.0).max(1.0);
+ self.zoom = ((1400.0 / world_w).min(870.0 / world_h) * 0.72).clamp(ZOOM_MIN, ZOOM_MAX);
+ let cx = (min_x + max_x) / 2.0;
+ let cy = (min_y + max_y) / 2.0;
+ self.pan = Vec2::new(-cx * self.zoom, -cy * self.zoom);
+ }
+
+ fn reload(&mut self) {
+ let db = self.db_path.clone();
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let _ = tx.send(load_graph(&db).map_err(|e| e.to_string()));
+ });
+ self.reload_pending = Some(rx);
+ self.set_msg("Reloading…".to_string(), Duration::from_secs(30));
+ }
+
+ fn poll_reload(&mut self) {
+ let result = match &self.reload_pending {
+ Some(rx) => rx.try_recv().ok(),
+ None => return,
+ };
+ if let Some(result) = result {
+ self.reload_pending = None;
+ match result {
+ Ok((raw_nodes, raw_edges)) => {
+ let added = raw_nodes.iter().filter(|n| !self.graph.nodes.contains_key(&n.id)).count();
+ let gone = self.graph.node_list.iter().filter(|n| raw_nodes.iter().all(|r| r.id != n.id)).count();
+ let nn = raw_nodes.len();
+ let ne = raw_edges.len();
+ let diff = if added + gone > 0 { format!(" +{added}/−{gone}") } else { String::new() };
+
+ let mut new_graph = Graph::new(raw_nodes, raw_edges);
+ new_graph.transplant_positions(&self.graph);
+
+ let old_sel = self.selected.clone();
+ self.node_ids = new_graph.node_list.iter().map(|n| n.id.clone()).collect();
+ self.graph = new_graph;
+ self.hovered = None;
+ self.node_dragging = None;
+ self.selected = old_sel.filter(|s| self.graph.nodes.contains_key(s.as_str()));
+ self.sel_idx = self.selected.as_ref()
+ .and_then(|s| self.node_ids.iter().position(|id| id == s))
+ .map(|i| i as i32).unwrap_or(-1);
+ self.set_msg(format!("Reloaded — {nn} nodes {ne} edges{diff}"), Duration::from_secs(3));
+ }
+ Err(e) => self.set_msg(format!("Reload failed: {e}"), Duration::from_secs(6)),
+ }
+ }
+ }
+
+ fn is_animating(&self) -> bool {
+ self.graph.physics_on
+ || self.pan_vel.length() > 0.5
+ || self.node_dragging.is_some()
+ || (self.help_anim - if self.help_visible { 1.0 } else { 0.0 }).abs() > 0.01
+ || self.status_msg.as_ref().map_or(false, |(_, t, d)| t.elapsed() < *d)
+ || self.reload_pending.is_some()
+ }
+
+ fn build_status(&self) -> String {
+ if self.search_active {
+ return format!(" Search: {}█ ESC: Cancel Enter: Jump", self.search_query);
+ }
+ if let Some((msg, start, dur)) = &self.status_msg {
+ if start.elapsed() < *dur { return format!(" {msg}"); }
+ }
+ let n = self.graph.node_list.len();
+ let e = self.graph.edges.len();
+ let mut filters = Vec::<&str>::new();
+ if self.show_tags { filters.push("Tags:On"); }
+ if self.hide_dailies { filters.push("Daily:On"); }
+ match self.local_hops { 1 => filters.push("Local:1hop"), 2 => filters.push("Local:2hop"), 3 => filters.push("Local:3hop"), _ => {} }
+ let filt = if filters.is_empty() { String::new() } else { format!(" · {}", filters.join(" ")) };
+
+ if let Some(sel) = &self.selected {
+ if let Some(&idx) = self.graph.nodes.get(sel) {
+ let title = &self.graph.node_list[idx].title;
+ let t = if title.len() > 52 { &title[..52] } else { title.as_str() };
+ return format!(" {t} · {n} Nodes · {e} Edges{filt}");
+ }
+ }
+ format!(" {n} Nodes · {e} Edges{filt}")
+ }
+
+ fn handle_keyboard(&mut self, ctx: &egui::Context) {
+ let held = ctx.input(|i| i.keys_down.clone());
+ self.help_visible = held.contains(&Key::H);
+
+ ctx.input(|i| {
+ for ev in &i.events {
+ match ev {
+ egui::Event::Key { key, pressed: true, repeat: false, .. } => {
+ if self.search_active {
+ match key {
+ Key::Escape => { self.search_active = false; self.search_query.clear(); }
+ Key::Enter => { self.search_commit(); self.search_active = false; }
+ Key::Backspace => { self.search_query.pop(); }
+ _ => {}
+ }
+ return;
+ }
+ match key {
+ Key::Q | Key::Escape => std::process::exit(0),
+ Key::T => {
+ self.theme_idx = (self.theme_idx + 1) % THEMES.len();
+ self.popup = Some((THEMES[self.theme_idx].name.to_string(), self.now_secs));
+ }
+ Key::P => {
+ if self.graph.physics_on {
+ self.graph.physics_on = false;
+ self.popup = Some(("Physics Paused".into(), self.now_secs));
+ } else {
+ self.graph.resume_physics();
+ self.popup = Some(("Physics Active".into(), self.now_secs));
+ }
+ }
+ Key::D => {
+ self.hide_dailies = !self.hide_dailies;
+ self.popup = Some((if self.hide_dailies { "Dailies Hidden".into() } else { "Dailies Shown".into() }, self.now_secs));
+ }
+ Key::O => {
+ self.hide_orphans = !self.hide_orphans;
+ self.popup = Some((if self.hide_orphans { "Orphans Hidden".into() } else { "Orphans Shown".into() }, self.now_secs));
+ }
+ Key::G => {
+ self.show_tags = !self.show_tags;
+ self.popup = Some((if self.show_tags { "Tag Colors On".into() } else { "Tag Colors Off".into() }, self.now_secs));
+ }
+ Key::A => {
+ self.show_age = !self.show_age;
+ self.popup = Some((if self.show_age { "Age View On".into() } else { "Age View Off".into() }, self.now_secs));
+ }
+ Key::V => { self.cycle_layout(); }
+ Key::F => {
+ self.show_fps = !self.show_fps;
+ self.popup = Some((if self.show_fps { "FPS On".into() } else { "FPS Off".into() }, self.now_secs));
+ }
+ Key::W => {
+ self.popup = Some(("Reloading…".into(), self.now_secs));
+ self.reload();
+ }
+ Key::R => {
+ self.fit_to_nodes();
+ self.popup = Some(("View Reset".into(), self.now_secs));
+ }
+ Key::L => {
+ self.local_hops = if self.local_hops >= 3 { 0 } else { self.local_hops + 1 };
+ let msg = match self.local_hops {
+ 0 => "Local Graph Off",
+ 1 => "Local Graph 1 Hop",
+ 2 => "Local Graph 2 Hops",
+ _ => "Local Graph 3 Hops",
+ };
+ self.popup = Some((msg.into(), self.now_secs));
+ }
+ Key::Slash => { self.search_active = true; self.search_query.clear(); }
+ Key::Tab => {
+ let n = self.node_ids.len().max(1) as i32;
+ let shift = i.modifiers.shift;
+ self.sel_idx = if shift { (self.sel_idx - 1).rem_euclid(n) } else { (self.sel_idx + 1) % n };
+ self.selected = self.node_ids.get(self.sel_idx as usize).cloned();
+ if let Some(ref nid) = self.selected.clone() {
+ if let Some(&idx) = self.graph.nodes.get(nid.as_str()) {
+ let title = self.graph.node_list[idx].title.clone();
+ let t = if title.chars().count() > 28 { title.chars().take(28).collect::<String>() + "…" } else { title };
+ self.popup = Some((t, self.now_secs));
+ }
+ }
+ }
+ Key::Enter | Key::Space => {
+ if let Some(nid) = self.selected.clone() {
+ self.open_node(&nid);
+ self.popup = Some(("Opening in Emacs…".into(), self.now_secs));
+ }
+ }
+ _ => {}
+ }
+ }
+ egui::Event::Text(text) if self.search_active => {
+ self.search_query.push_str(text);
+ }
+ _ => {}
+ }
+ }
+ });
+ }
+
+ fn handle_pointer(&mut self, ctx: &egui::Context, hidden: &HashSet<String>) {
+ let pointer = ctx.input(|i| i.pointer.clone());
+ let scroll = ctx.input(|i| i.smooth_scroll_delta);
+ let hover_pos = pointer.hover_pos();
+
+ // Hover
+ if !self.pan_dragging && self.node_dragging.is_none() {
+ if let Some(hp) = hover_pos {
+ if self.graph_rect.contains(hp) {
+ let new_hov = self.node_at(hp.x, hp.y, hidden);
+ self.hovered = new_hov;
+ } else {
+ self.hovered = None;
+ }
+ }
+ }
+
+ // Scroll/zoom
+ if scroll.y.abs() > 0.1 {
+ if let Some(hp) = hover_pos {
+ if self.graph_rect.contains(hp) {
+ let steps = (scroll.y / 20.0).clamp(-5.0, 5.0);
+ let factor = ZOOM_STEP.powf(steps);
+ let cx = self.graph_rect.center().x;
+ let cy = self.graph_rect.center().y;
+ let wx = ((hp.x - cx - self.pan.x) / self.zoom) as f64;
+ let wy = ((hp.y - cy - self.pan.y) / self.zoom) as f64;
+ self.zoom = (self.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
+ self.pan.x = hp.x - cx - wx as f32 * self.zoom;
+ self.pan.y = hp.y - cy - wy as f32 * self.zoom;
+ }
+ }
+ }
+
+ // Drag start
+ if pointer.any_pressed() {
+ if let Some(pos) = pointer.press_origin() {
+ if self.graph_rect.contains(pos) {
+ let hit = self.node_at(pos.x, pos.y, hidden);
+ if let Some(nid) = hit {
+ // Node drag — record offset from click point to node centre so
+ // the node doesn't jump when clicked away from its centre
+ let (nw_x, nw_y) = if let Some(&idx) = self.graph.nodes.get(&nid) {
+ (self.graph.node_list[idx].x, self.graph.node_list[idx].y)
+ } else { (0.0, 0.0) };
+ let (press_wx, press_wy) = self.s2w(pos.x, pos.y);
+ self.node_drag_origin_s = pos;
+ self.node_drag_origin_w = (press_wx - nw_x, press_wy - nw_y);
+ self.node_dragging = Some(nid.clone());
+ self.node_drag_samples.clear();
+ if let Some(&idx) = self.graph.nodes.get(&nid) {
+ self.graph.node_list[idx].pinned = true;
+ self.graph.node_list[idx].vx = 0.0;
+ self.graph.node_list[idx].vy = 0.0;
+ }
+ self.selected = Some(nid.clone());
+ self.sel_idx = self.node_ids.iter().position(|id| id == &nid).map(|i| i as i32).unwrap_or(-1);
+
+ // Double-click
+ let now_i = Instant::now();
+ let is_double = self.last_click.as_ref()
+ .map_or(false, |(lid, lt)| lid == &nid && now_i.duration_since(*lt) < Duration::from_millis(350));
+ if is_double { self.open_node(&nid); }
+ self.last_click = Some((nid, now_i));
+ } else {
+ self.pan_dragging = true;
+ self.pan_origin_m = pos;
+ self.pan_origin_v = self.pan;
+ self.pan_vel = Vec2::ZERO;
+ self.drag_samples.clear();
+ }
+ }
+ }
+ }
+
+ // Drag motion
+ if pointer.is_moving() || pointer.any_down() {
+ if let Some(cur) = hover_pos {
+ if let Some(nid) = self.node_dragging.clone() {
+ let (wx, wy) = self.s2w(cur.x, cur.y);
+ let (off_x, off_y) = self.node_drag_origin_w;
+ if let Some(&idx) = self.graph.nodes.get(&nid) {
+ self.graph.node_list[idx].x = wx - off_x;
+ self.graph.node_list[idx].y = wy - off_y;
+ let now_i = Instant::now();
+ self.node_drag_samples.push((now_i, wx - off_x, wy - off_y));
+ let cutoff = now_i - Duration::from_millis(70);
+ self.node_drag_samples.retain(|(t, _, _)| *t >= cutoff);
+ }
+ } else if self.pan_dragging {
+ self.pan = self.pan_origin_v + (cur - self.pan_origin_m);
+ let now_i = Instant::now();
+ self.drag_samples.push((now_i, cur.x, cur.y));
+ let cutoff = now_i - Duration::from_millis(40);
+ self.drag_samples.retain(|(t, _, _)| *t >= cutoff);
+ }
+ }
+ }
+
+ // Drag release
+ if pointer.any_released() {
+ if let Some(nid) = self.node_dragging.take() {
+ if let Some(&idx) = self.graph.nodes.get(&nid) {
+ self.graph.node_list[idx].pinned = false;
+ let s = &self.node_drag_samples;
+ if s.len() >= 2 {
+ let span = s.last().unwrap().0.duration_since(s[0].0).as_secs_f64();
+ if span > 0.001 {
+ let dvx = (s.last().unwrap().1 - s[0].1) / span;
+ let dvy = (s.last().unwrap().2 - s[0].2) / span;
+ let spd = dvx.hypot(dvy);
+ if spd > 6.0 {
+ let cap = 1100.0;
+ let (dvx, dvy) = if spd > cap { (dvx/spd*cap, dvy/spd*cap) } else { (dvx, dvy) };
+ self.graph.node_list[idx].vx = dvx;
+ self.graph.node_list[idx].vy = dvy;
+ self.graph.physics_on = true;
+ }
+ }
+ }
+ self.node_drag_samples.clear();
+ }
+ }
+ if self.pan_dragging {
+ // Small displacement = click on empty space → clear selection
+ let pan_delta = (self.pan - self.pan_origin_v).length();
+ if pan_delta < 8.0 {
+ self.selected = None;
+ self.sel_idx = -1;
+ }
+ let s = &self.drag_samples;
+ if s.len() >= 2 {
+ let span = s.last().unwrap().0.duration_since(s[0].0).as_secs_f32();
+ if span > 0.001 {
+ let vx = (s.last().unwrap().1 - s[0].1) / span;
+ let vy = (s.last().unwrap().2 - s[0].2) / span;
+ if vx.hypot(vy) > 120.0 {
+ self.pan_vel = Vec2::new(vx, vy);
+ }
+ }
+ }
+ self.drag_samples.clear();
+ self.pan_dragging = false;
+ }
+ }
+ }
+
+ fn paint_help(&self, painter: &egui::Painter, rect: Rect, t: Theme) {
+ let rows: &[(&str, &str)] = &[
+ ("Mouse / Trackpad", ""),
+ (" Drag background", "Pan view"),
+ (" Scroll", "Zoom toward cursor"),
+ (" Click node", "Select"),
+ (" Double-click node", "Open in Emacs"),
+ ("", ""),
+ ("Keyboard", ""),
+ (" Tab / Shift-Tab", "Cycle nodes"),
+ (" Enter / Space", "Open selected in Emacs"),
+ (" T", "Cycle theme"),
+ (" G", "Toggle tag colouring"),
+ (" A", "Toggle age heatmap"),
+ (" D", "Toggle daily notes filter"),
+ (" O", "Toggle orphan filter"),
+ (" L", "Cycle local graph (1–3 hops / off)"),
+ (" /", "Search by title or alias"),
+ (" V", "Cycle layout (Disk → Column → Tree)"),
+ (" W", "Reload graph from database"),
+ (" F", "Toggle FPS counter"),
+ (" P", "Pause / resume physics"),
+ (" R", "Reset view"),
+ (" Q / Escape", "Quit"),
+ ("", ""),
+ (" H", "Hold to show this menu"),
+ ];
+ let row_h = 22.0;
+ let pad = 18.0;
+ let panel_w = 510.0;
+ let head_h = 38.0;
+ let n_rows = rows.iter().filter(|&&(k,v)| !k.is_empty()||!v.is_empty()).count();
+ let n_gaps = rows.iter().filter(|&&(k,v)| k.is_empty()&&v.is_empty()).count();
+ let panel_h = head_h + n_rows as f32 * row_h + n_gaps as f32 * row_h / 2.0 + pad;
+
+ let ease = 1.0 - (1.0 - self.help_anim).powi(3);
+ let panel_x = rect.left() + (rect.width() - panel_w) / 2.0;
+ let panel_y = rect.top() + (-panel_h * (1.0 - ease)) + 6.0;
+ let panel_rect = Rect::from_min_size(Pos2::new(panel_x, panel_y), Vec2::new(panel_w, panel_h));
+
+ painter.rect_filled(panel_rect, 6.0,
+ Color32::from_rgba_unmultiplied(t.bar_bg.r(), t.bar_bg.g(), t.bar_bg.b(), 242));
+ painter.rect_stroke(panel_rect, 6.0, Stroke::new(1.0, t.bar_line));
+ painter.line_segment(
+ [Pos2::new(panel_x+pad, panel_y+head_h), Pos2::new(panel_x+panel_w-pad, panel_y+head_h)],
+ Stroke::new(1.0, t.bar_line),
+ );
+ painter.text(Pos2::new(panel_x+pad, panel_y+head_h/2.0), egui::Align2::LEFT_CENTER,
+ "Controls", FontId::proportional(15.0), t.label_hov);
+
+ let mut y = panel_y + head_h + pad / 2.0;
+ for &(key_s, val_s) in rows {
+ if key_s.is_empty() && val_s.is_empty() { y += row_h / 2.0; continue; }
+ let font = FontId::proportional(13.0);
+ if !key_s.is_empty() {
+ let col = if val_s.is_empty() { t.label_hov } else { t.label };
+ painter.text(Pos2::new(panel_x+pad, y+row_h/2.0), egui::Align2::LEFT_CENTER, key_s, font.clone(), col);
+ }
+ if !val_s.is_empty() {
+ painter.text(Pos2::new(panel_x+pad+215.0, y+row_h/2.0), egui::Align2::LEFT_CENTER, val_s, font, t.bar_text);
+ }
+ y += row_h;
+ }
+ }
+}
+
+impl NaviApp {
+ /// Whether the app should be rendering at full display rate this frame.
+ /// Cadence rules (driven by the custom event loop in main.rs):
+ /// * Anything actively animating (physics, layout, drag, pan inertia) → full speed.
+ /// * Window unfocused → idle (always; saves power when you're elsewhere).
+ /// * Window focused but no input for `idle_grace` → idle.
+ /// * Otherwise → full speed (the "you're using the app" mode).
+ pub fn needs_full_speed(&self) -> bool {
+ let actively_animating = self.graph.physics_on
+ || self.graph.is_layout_animating()
+ || self.pan_vel.length_sq() > 4.0
+ || self.pan_dragging
+ || self.node_dragging.is_some();
+ if actively_animating {
+ return true;
+ }
+ if !self.window_focused {
+ return false;
+ }
+ self.last_active_at.elapsed() < self.idle_grace
+ }
+
+ /// Called by main.rs when the OS reports the window's focus state changes.
+ /// On focus gain we immediately reset the activity clock so the next paint
+ /// flips us back into full-speed mode without waiting on input.
+ pub fn set_focused(&mut self, focused: bool) {
+ self.window_focused = focused;
+ if focused {
+ self.last_active_at = Instant::now();
+ }
+ }
+
+ /// Called by main.rs for any user input event (mouse, scroll, keyboard, IME).
+ /// Bumps the activity clock so the idle grace timer restarts.
+ pub fn touch_input(&mut self) {
+ self.last_active_at = Instant::now();
+ }
+
+ /// Current theme background as `[r, g, b]` floats — used by main.rs to
+ /// `glClear` the surface before egui paints into it.
+ pub fn bg_color(&self) -> [f32; 3] {
+ let bg = self.theme().bg;
+ [
+ bg.r() as f32 / 255.0,
+ bg.g() as f32 / 255.0,
+ bg.b() as f32 / 255.0,
+ ]
+ }
+
+ /// Egui frame entry point. Driven by main.rs at exactly the panel's vsync
+ /// rate (240 Hz on the user's display) when active, or a 33 ms timer when
+ /// idle. Mouse / keyboard events do *not* trigger renders directly — they
+ /// only mutate input state via egui_winit, which we read here.
+ pub fn update(&mut self, ctx: &egui::Context) {
+ // ── Frame timing ──────────────────────────────────────────────────────
+ let now = Instant::now();
+ let dt = now.duration_since(self.last_frame).as_secs_f32().min(0.05);
+ self.last_frame = now;
+ self.now_secs = unix_now();
+ self.frame_times.push_back(dt);
+ // Keep only the last 1 second of frame times
+ while self.frame_times.iter().sum::<f32>() > 1.0 && self.frame_times.len() > 2 {
+ self.frame_times.pop_front();
+ }
+
+ // NAVI_FPS_LOG output lives in main.rs's `redraw` — that's the only
+ // place that has authoritative paint timing. The earlier copy in this
+ // function ran out of phase with main.rs and produced doubled prints.
+
+ // ── Updates ───────────────────────────────────────────────────────────
+ self.poll_reload();
+
+ // Fixed 60 Hz physics — decoupled from render rate so vsync at any
+ // frequency doesn't change how fast nodes move or how much CPU physics uses.
+ const PHYS_DT: f64 = 1.0 / 60.0;
+ self.physics_accumulator = (self.physics_accumulator + dt as f64).min(PHYS_DT * 5.0);
+ while self.physics_accumulator >= PHYS_DT {
+ self.graph.step(PHYS_DT);
+ self.physics_accumulator -= PHYS_DT;
+ }
+
+ if !self.pan_dragging && self.pan_vel.length() > 0.5 {
+ self.pan += self.pan_vel * dt;
+ self.pan_vel *= PAN_DECAY.powf(dt / BASE_DT);
+ }
+
+ let help_target = if self.help_visible { 1.0f32 } else { 0.0 };
+ self.help_anim += (help_target - self.help_anim) * (10.0 * dt).min(1.0);
+
+ // ── Visibility sets (computed once, used in input + render) ───────────
+ let hidden = self.build_hidden();
+
+ // ── Input (all mutations before painter borrows) ───────────────────────
+ self.handle_keyboard(ctx);
+ self.handle_pointer(ctx, &hidden);
+
+ // ── Render ────────────────────────────────────────────────────────────
+ let t = self.theme();
+ let status_text = self.build_status();
+
+ egui::TopBottomPanel::bottom("status")
+ .exact_height(STATUS_H)
+ .frame(egui::Frame::none().fill(t.bar_bg).stroke(Stroke::new(1.0, t.bar_line)))
+ .show(ctx, |ui| {
+ ui.horizontal_centered(|ui| {
+ ui.add_space(10.0);
+ ui.label(egui::RichText::new(&status_text).color(t.bar_text).size(13.0));
+ if self.show_fps && self.frame_times.len() >= 2 {
+ let total = self.frame_times.iter().sum::<f32>();
+ let fps_str = format!("{:.0} fps", self.frame_times.len() as f32 / total);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.add_space(10.0);
+ ui.label(egui::RichText::new(fps_str).color(t.bar_text).size(12.0));
+ });
+ }
+ });
+ });
+
+ egui::CentralPanel::default()
+ .frame(egui::Frame::none().fill(t.bg))
+ .show(ctx, |ui| {
+ let graph_rect = ui.available_rect_before_wrap();
+ self.graph_rect = graph_rect; // cache for next frame's input
+ ui.allocate_rect(graph_rect, Sense::hover());
+ let painter = ui.painter_at(graph_rect);
+
+ let faded = self.build_faded(&hidden);
+ let hov_adj: HashSet<String> = self.hovered.as_ref()
+ .and_then(|h| self.graph.adj.get(h)).cloned()
+ .unwrap_or_default().into_iter()
+ .chain(self.hovered.iter().cloned()).collect();
+
+ let gp = GraphPainter {
+ graph: &self.graph,
+ theme: t,
+ pan: self.pan,
+ zoom: self.zoom,
+ hovered: self.hovered.as_deref(),
+ selected: self.selected.as_deref(),
+ hidden: &hidden,
+ faded: &faded,
+ hov_adj,
+ show_tags: self.show_tags,
+ show_age: self.show_age,
+ now_secs: self.now_secs,
+ graph_rect,
+ };
+
+ let prof = std::env::var_os("NAVI_PROF").is_some();
+ let no_grid = std::env::var_os("NAVI_NO_GRID").is_some();
+ let no_edges = std::env::var_os("NAVI_NO_EDGES").is_some();
+ let no_nodes = std::env::var_os("NAVI_NO_NODES").is_some();
+ let no_labels = std::env::var_os("NAVI_NO_LABELS").is_some();
+
+ let t_grid = Instant::now();
+ if !no_grid { gp.paint_grid(&painter); }
+ let t_edges = Instant::now();
+ if !no_edges { gp.paint_edges(&painter); }
+ let t_nodes = Instant::now();
+ if !no_nodes { gp.paint_nodes(&painter); }
+ let t_labels = Instant::now();
+ if !no_labels && self.zoom > 0.25 { gp.paint_labels(&painter); }
+ let t_help = Instant::now();
+ if self.help_anim > 0.01 { self.paint_help(&painter, graph_rect, t); }
+ let t_end = Instant::now();
+
+ if prof {
+ let due = self.last_prof_log
+ .map_or(true, |ts: Instant| ts.elapsed() >= Duration::from_secs(1));
+ if due {
+ let us = |a: Instant, b: Instant| (b - a).as_micros();
+ eprintln!(
+ "navi: paint grid={}us edges={}us nodes={}us labels={}us help={}us post_overhead={}us",
+ us(t_grid, t_edges),
+ us(t_edges, t_nodes),
+ us(t_nodes, t_labels),
+ us(t_labels, t_help),
+ us(t_help, t_end),
+ self.post_overhead.as_micros(),
+ );
+ self.last_prof_log = Some(Instant::now());
+ }
+ }
+ });
+
+ // ── Theme name popup ──────────────────────────────────────────────────
+ if let Some((ref name, start)) = self.popup.clone() {
+ let elapsed = (self.now_secs - start) as f32;
+ let alpha: f32 = if elapsed < 0.25 {
+ elapsed / 0.25 // fade in
+ } else if elapsed < 1.1 {
+ 1.0 // hold
+ } else if elapsed < 1.7 {
+ (1.7 - elapsed) / 0.6 // fade out
+ } else {
+ self.popup = None;
+ 0.0
+ };
+ if alpha > 0.01 {
+ let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
+ let t = THEMES[self.theme_idx];
+ let fid = egui::epaint::FontId::proportional(14.0);
+ let painter = ctx.layer_painter(egui::LayerId::new(
+ egui::Order::Foreground, egui::Id::new("popup"),
+ ));
+ // Measure text first so we can size the background pill
+ let galley = ctx.fonts(|f| f.layout_no_wrap(
+ name.clone(), fid.clone(),
+ egui::Color32::WHITE,
+ ));
+ let pad = egui::Vec2::new(14.0, 7.0);
+ let rect = egui::Rect::from_min_size(
+ egui::Pos2::new(18.0, 12.0),
+ galley.size() + pad * 2.0,
+ );
+ painter.rect_filled(rect, 8.0,
+ egui::Color32::from_rgba_unmultiplied(t.bar_bg.r(), t.bar_bg.g(), t.bar_bg.b(), (a as f32 * 0.92) as u8));
+ painter.rect_stroke(rect, 8.0,
+ egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(t.bar_line.r(), t.bar_line.g(), t.bar_line.b(), a)));
+ painter.text(rect.center(), egui::Align2::CENTER_CENTER, name,
+ fid, egui::Color32::from_rgba_unmultiplied(t.label_hov.r(), t.label_hov.g(), t.label_hov.b(), a));
+ }
+ }
+
+ // Frame cadence is driven entirely by main.rs:
+ // * macOS: a CADisplayLink running on a dedicated background thread sends
+ // `UserEvent::Vsync` to the main loop on every panel refresh (240 Hz on
+ // the test display). main.rs calls `request_redraw` only on those events
+ // plus the 33 ms idle timer when `needs_full_speed()` is false.
+ // * egui_winit's per-event "please repaint" hint is *deliberately ignored*
+ // in main.rs, so mouse motion no longer drives extra renders. That is
+ // what unlocks the locked-240-fps low-CPU mode.
+ //
+ // We therefore do nothing here at the end of update() — no `request_repaint`,
+ // no sleep, no pacer. The whole loop is cleanly owned by main.rs.
+ let _ = ctx; // (still passed in case future code needs it)
+ let _ = self.post_overhead;
+ let _ = self.last_update_end;
+ }
+}
+
+
+fn unix_now() -> f64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs_f64()
+}
diff --git a/navi/src/macos_display.rs b/navi/src/macos_display.rs
@@ -0,0 +1,267 @@
+//! macOS display tier control + vsync source.
+//!
+//! Architecture:
+//! * A dedicated background thread owns a `CADisplayLink` attached to its own
+//! `NSRunLoop`. The main thread cannot starve it (it has no work running on
+//! the bg thread other than the link callback).
+//! * On each vsync the callback sends `UserEvent::Vsync` to winit's main
+//! event loop via `EventLoopProxy`. main.rs treats that as "please paint".
+//! * `set_active(true)` resumes the link; `set_active(false)` pauses it (the
+//! OS may drop the panel back to a lower refresh tier when paused, saving
+//! power). The bg thread itself stays alive for the process lifetime.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Mutex, OnceLock};
+
+use objc2::rc::Retained;
+use objc2::runtime::AnyObject;
+use objc2::{define_class, msg_send, sel, AllocAnyThread};
+use objc2_app_kit::NSScreen;
+use objc2_foundation::{
+ MainThreadMarker, NSDate, NSObject, NSObjectProtocol, NSRunLoop, NSRunLoopMode,
+};
+use objc2_quartz_core::{CADisplayLink, CAFrameRateRange};
+use raw_window_handle::{HasWindowHandle, RawWindowHandle};
+use winit::event_loop::EventLoopProxy;
+
+use crate::UserEvent;
+
+// ─── Atomics for diagnostics + pacer queries ──────────────────────────────────
+
+static ORIGIN: OnceLock<std::time::Instant> = OnceLock::new();
+static LAST_VSYNC_NS: AtomicU64 = AtomicU64::new(0);
+static VSYNC_INTERVAL_US: AtomicU64 = AtomicU64::new(0);
+static TICK_COUNT: AtomicU64 = AtomicU64::new(0);
+
+/// Pause-state for the link. `true` = run; `false` = paused.
+static LINK_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+
+// ─── Cross-thread plumbing ────────────────────────────────────────────────────
+
+struct ProxyHandle(EventLoopProxy<UserEvent>);
+// SAFETY: `EventLoopProxy<UserEvent>` is `Send` and we only read+clone on the
+// bg thread, never share a `&` reference across threads.
+unsafe impl Send for ProxyHandle {}
+unsafe impl Sync for ProxyHandle {}
+
+/// Holds the proxy that the link's tick callback uses to wake the main loop.
+static EVENT_PROXY: Mutex<Option<ProxyHandle>> = Mutex::new(None);
+
+/// Holds a pointer to the running link so `set_active` can call `setPaused:`.
+/// Stored as raw `*mut CADisplayLink` because `Retained` isn't `Send` and the
+/// link lives forever on the bg thread anyway.
+struct LinkPtr(*mut CADisplayLink);
+// SAFETY: we only use the pointer to invoke `setPaused:` which is documented
+// to be safe to call from any thread.
+unsafe impl Send for LinkPtr {}
+unsafe impl Sync for LinkPtr {}
+static LINK: OnceLock<LinkPtr> = OnceLock::new();
+
+// ─── Objective-C delegate target ──────────────────────────────────────────────
+
+define_class!(
+ #[unsafe(super(NSObject))]
+ #[name = "NaviDisplayLinkTarget"]
+ #[derive(Debug)]
+ struct LinkTarget;
+
+ unsafe impl NSObjectProtocol for LinkTarget {}
+
+ impl LinkTarget {
+ #[unsafe(method(tick:))]
+ fn tick(&self, link: &CADisplayLink) {
+ let now = std::time::Instant::now();
+ let since = now.duration_since(*ORIGIN.get_or_init(std::time::Instant::now));
+ LAST_VSYNC_NS.store(since.as_nanos() as u64, Ordering::Relaxed);
+ TICK_COUNT.fetch_add(1, Ordering::Relaxed);
+
+ unsafe {
+ let dur: f64 = msg_send![link, duration];
+ if dur > 0.0 && dur.is_finite() {
+ VSYNC_INTERVAL_US.store((dur * 1_000_000.0) as u64, Ordering::Relaxed);
+ }
+ }
+
+ // Wake the main thread — this is what drives the actual paint cadence.
+ if let Ok(g) = EVENT_PROXY.lock() {
+ if let Some(p) = g.as_ref() {
+ let _ = p.0.send_event(UserEvent::Vsync);
+ }
+ }
+ }
+ }
+);
+
+// ─── Public API ───────────────────────────────────────────────────────────────
+
+/// Spawn the dedicated bg thread, create a `CADisplayLink` pinned to the panel's
+/// max refresh rate, and start it paused. Call once at startup, after the window
+/// has been created (we need the NSScreen the window is on).
+pub fn install_bg_link(window: &winit::window::Window, proxy: EventLoopProxy<UserEvent>) {
+ let _ = ORIGIN.get_or_init(std::time::Instant::now);
+
+ // Stash the proxy where the bg thread's link callback can reach it.
+ if let Ok(mut g) = EVENT_PROXY.lock() {
+ *g = Some(ProxyHandle(proxy));
+ }
+
+ // Determine the panel's max refresh rate. NSScreen is not strictly
+ // main-thread-only, but `screen()` on an NSWindow is — fetch it now.
+ let max_fps: f32 = match max_fps_from_window(window) {
+ Some(f) => {
+ eprintln!("navi: display maximumFramesPerSecond = {}", f as u32);
+ f
+ }
+ None => {
+ eprintln!("navi: could not query NSScreen.maximumFramesPerSecond — defaulting to 240 Hz");
+ 240.0
+ }
+ };
+
+ std::thread::Builder::new()
+ .name("navi-display-link".into())
+ .spawn(move || run_bg_link(max_fps))
+ .expect("spawn navi-display-link thread");
+}
+
+/// Pause / unpause the link. Safe to call from the main thread (or anywhere).
+pub fn set_active(active: bool) {
+ LINK_ACTIVE.store(active, Ordering::Relaxed);
+ if let Some(ptr) = LINK.get() {
+ // SAFETY: `setPaused:` is safe to call on `CADisplayLink` from any thread.
+ unsafe {
+ let link: &CADisplayLink = &*ptr.0;
+ link.setPaused(!active);
+ }
+ }
+}
+
+/// Most recent display vsync timestamp. Mostly for diagnostics.
+pub fn _last_vsync() -> Option<std::time::Instant> {
+ let ns = LAST_VSYNC_NS.load(Ordering::Relaxed);
+ if ns == 0 {
+ return None;
+ }
+ let origin = *ORIGIN.get()?;
+ Some(origin + std::time::Duration::from_nanos(ns))
+}
+
+/// Last reported display vsync interval (e.g. 4166 µs at 240 Hz).
+pub fn vsync_interval() -> Option<std::time::Duration> {
+ let us = VSYNC_INTERVAL_US.load(Ordering::Relaxed);
+ if us == 0 {
+ None
+ } else {
+ Some(std::time::Duration::from_micros(us))
+ }
+}
+
+pub fn tick_count() -> u64 {
+ TICK_COUNT.load(Ordering::Relaxed)
+}
+
+// ─── Internals ────────────────────────────────────────────────────────────────
+
+/// Body of the bg thread. Sets up the link, adds it to this thread's run loop,
+/// then runs the loop forever.
+fn run_bg_link(max_fps: f32) {
+ let target: Retained<LinkTarget> = unsafe {
+ let alloc = LinkTarget::alloc();
+ msg_send![alloc, init]
+ };
+ let target_obj: &AnyObject =
+ unsafe { &*((&*target) as *const LinkTarget as *const AnyObject) };
+
+ // We can construct the link from any NSScreen instance. NSScreen.mainScreen()
+ // works fine on a non-main thread for read access on modern macOS.
+ let link: Retained<CADisplayLink> = unsafe {
+ let screen_opt = bg_screen();
+ let screen: &NSScreen = match screen_opt.as_deref() {
+ Some(s) => s,
+ None => {
+ eprintln!("navi: bg-thread display link could not find an NSScreen — pacer disabled");
+ return;
+ }
+ };
+ screen.displayLinkWithTarget_selector(target_obj, sel!(tick:))
+ };
+
+ // Pin to the panel's max refresh tier (240 Hz on the user's display).
+ let range = CAFrameRateRange::new(max_fps, max_fps, max_fps);
+ link.setPreferredFrameRateRange(range);
+
+ // Start paused. main.rs will call set_active(true) when interaction begins.
+ link.setPaused(true);
+
+ // Install on this thread's run loop.
+ unsafe {
+ let runloop = NSRunLoop::currentRunLoop();
+ extern "C" {
+ static NSRunLoopCommonModes: *const NSRunLoopMode;
+ }
+ let mode: &NSRunLoopMode = &*NSRunLoopCommonModes;
+ link.addToRunLoop_forMode(&runloop, mode);
+ }
+
+ eprintln!(
+ "navi: CADisplayLink active on bg thread @ {} Hz (preferred range {}..{})",
+ max_fps as u32, max_fps as u32, max_fps as u32
+ );
+
+ let raw_link = Retained::as_ptr(&link).cast_mut();
+ let _ = LINK.set(LinkPtr(raw_link));
+
+ // Hold the link Retained alive forever — it runs for the process lifetime.
+ std::mem::forget(link);
+ std::mem::forget(target);
+
+ // Drive the run loop. `runMode:beforeDate:distantFuture` blocks until events
+ // arrive (link ticks) and runs callbacks, then returns. We loop forever.
+ loop {
+ unsafe {
+ let runloop = NSRunLoop::currentRunLoop();
+ extern "C" {
+ static NSDefaultRunLoopMode: *const NSRunLoopMode;
+ }
+ let mode: &NSRunLoopMode = &*NSDefaultRunLoopMode;
+ // distantFuture so the loop only returns when a source fires.
+ let until = NSDate::distantFuture();
+ let _ran: bool = msg_send![&runloop, runMode: mode, beforeDate: &*until];
+ }
+ }
+}
+
+/// Read NSScreen on the bg thread. Apple permits this for read-only access on
+/// modern macOS; we use `NSScreen.mainScreen` which returns the screen the
+/// keyboard focus window is on (close enough for our purposes).
+fn bg_screen() -> Option<Retained<NSScreen>> {
+ // Avoid `MainThreadMarker::new()` — we're explicitly off-main here. Pull
+ // mainScreen via runtime msg_send; on macOS 10.15+ this is documented to
+ // work from any thread.
+ unsafe {
+ let cls = objc2::class!(NSScreen);
+ let s: Option<Retained<NSScreen>> = msg_send![cls, mainScreen];
+ s
+ }
+}
+
+fn max_fps_from_window(window: &winit::window::Window) -> Option<f32> {
+ let mtm = MainThreadMarker::new()?;
+ let _ = mtm; // ensure we're on main when we touch NSWindow.screen
+ let handle = window.window_handle().ok()?;
+ let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
+ return None;
+ };
+ unsafe {
+ let view: &objc2_app_kit::NSView =
+ &*appkit.ns_view.cast::<objc2_app_kit::NSView>().as_ptr();
+ let win = view.window()?;
+ let screen = win.screen()?;
+ let fps = screen.maximumFramesPerSecond();
+ if fps > 0 {
+ Some(fps as f32)
+ } else {
+ None
+ }
+ }
+}
diff --git a/navi/src/main.rs b/navi/src/main.rs
@@ -0,0 +1,528 @@
+mod app;
+mod painter;
+mod theme;
+
+#[cfg(target_os = "macos")]
+mod macos_display;
+
+use std::num::NonZeroU32;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use egui_winit::winit;
+use glutin::context::NotCurrentGlContext;
+use glutin::display::{GetGlDisplay, GlDisplay};
+use glutin::prelude::GlSurface;
+use raw_window_handle::HasWindowHandle;
+use winit::application::ApplicationHandler;
+use winit::event::WindowEvent;
+use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
+use winit::window::Window;
+
+use navi_core::{
+ config::{detect_db, expand_tilde, Config},
+ load_graph, Graph,
+};
+
+/// Cross-thread events delivered to winit's main event loop.
+///
+/// `Vsync` is the heart of the new pacer: a dedicated background thread runs a
+/// `CADisplayLink` against its own NSRunLoop, and on each tick (4.17 ms on this
+/// 240 Hz panel) it sends one of these to the main thread. We render exactly
+/// once per vsync — no mouse-driven extra repaints, no main-thread sleeps.
+#[derive(Debug)]
+pub enum UserEvent {
+ Vsync,
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let mut cfg = Config::load();
+ if cfg.db.is_empty() {
+ cfg.db = detect_db();
+ }
+ if cfg.emacsclient.is_empty() {
+ cfg.emacsclient = navi_core::EmacsClient::new("", &cfg.server_name)
+ .binary
+ .clone();
+ }
+ let db_path = expand_tilde(&cfg.db);
+
+ let (raw_nodes, raw_edges) = load_graph(&db_path).map_err(|e| {
+ eprintln!("navi: failed to load {db_path}: {e}");
+ e
+ })?;
+ let n_nodes = raw_nodes.len();
+ let n_edges = raw_edges.len();
+ let graph = Graph::new(raw_nodes, raw_edges);
+ eprintln!("navi: loaded {n_nodes} nodes, {n_edges} edges");
+ cfg.save();
+
+ let event_loop = EventLoop::<UserEvent>::with_user_event().build()?;
+ event_loop.set_control_flow(ControlFlow::Wait);
+ let proxy = event_loop.create_proxy();
+
+ let mut app_state = AppState::new(graph, cfg, db_path, proxy);
+ event_loop.run_app(&mut app_state)?;
+ Ok(())
+}
+
+/// All runtime state owned by the winit `ApplicationHandler`.
+struct AppState {
+ proxy: EventLoopProxy<UserEvent>,
+ graph: Option<Graph>,
+ cfg: Option<Config>,
+ db_path: Option<String>,
+
+ // Created on `resumed` once we have an `ActiveEventLoop`.
+ gl_window: Option<GlutinWindow>,
+ gl: Option<Arc<glow::Context>>,
+ egui_glow: Option<egui_glow::EguiGlow>,
+ app: Option<app::NaviApp>,
+
+ // Idle scheduling: when we're not active, we don't run the display link;
+ // we wake on a timer instead. `next_idle_wake` tells `new_events` that the
+ // resume-time fired and a redraw is due.
+ next_idle_wake: Option<Instant>,
+
+ // Stats
+ frame_times: std::collections::VecDeque<f32>,
+ last_frame: Instant,
+ last_fps_log: Option<Instant>,
+
+ // Wall-clock of the last actual paint. Used to coalesce back-to-back
+ // redraw triggers (e.g. a Vsync user-event arriving microseconds after a
+ // RedrawRequested from a focus/input event). At 240 Hz the vsync window is
+ // ~4.17 ms; anything closer than half that is duplicate work that just
+ // contends with the compositor for the same presentation slot.
+ last_paint_at: Option<Instant>,
+}
+
+impl AppState {
+ fn new(graph: Graph, cfg: Config, db_path: String, proxy: EventLoopProxy<UserEvent>) -> Self {
+ Self {
+ proxy,
+ graph: Some(graph),
+ cfg: Some(cfg),
+ db_path: Some(db_path),
+ gl_window: None,
+ gl: None,
+ egui_glow: None,
+ app: None,
+ next_idle_wake: None,
+ frame_times: std::collections::VecDeque::new(),
+ last_frame: Instant::now(),
+ last_fps_log: None,
+ last_paint_at: None,
+ }
+ }
+}
+
+impl ApplicationHandler<UserEvent> for AppState {
+ fn resumed(&mut self, event_loop: &ActiveEventLoop) {
+ if self.gl_window.is_some() {
+ return;
+ }
+
+ let (gl_window, gl) = unsafe { GlutinWindow::create(event_loop) };
+ let gl = Arc::new(gl);
+ gl_window.window().set_visible(true);
+
+ let egui_glow = egui_glow::EguiGlow::new(event_loop, gl.clone(), None, None, true);
+ setup_fonts(&egui_glow.egui_ctx);
+
+ // Spawn the display link on a dedicated bg thread. From there it sends
+ // `UserEvent::Vsync` to the main thread on every tick. The link is
+ // started in the *paused* state — we'll resume it whenever the app
+ // enters its full-speed mode.
+ #[cfg(target_os = "macos")]
+ macos_display::install_bg_link(gl_window.window(), self.proxy.clone());
+
+ let graph = self.graph.take().expect("graph initialised in main()");
+ let cfg = self.cfg.take().expect("cfg initialised in main()");
+ let db_path = self.db_path.take().expect("db_path initialised in main()");
+ let app = app::NaviApp::new(graph, cfg, db_path);
+
+ self.gl_window = Some(gl_window);
+ self.gl = Some(gl);
+ self.egui_glow = Some(egui_glow);
+ self.app = Some(app);
+ }
+
+ fn window_event(
+ &mut self,
+ event_loop: &ActiveEventLoop,
+ _window_id: winit::window::WindowId,
+ event: WindowEvent,
+ ) {
+ if matches!(event, WindowEvent::CloseRequested | WindowEvent::Destroyed) {
+ event_loop.exit();
+ return;
+ }
+ if let WindowEvent::Resized(size) = &event {
+ if let Some(gw) = self.gl_window.as_ref() {
+ gw.resize(*size);
+ }
+ }
+
+ if matches!(event, WindowEvent::RedrawRequested) {
+ self.redraw(event_loop);
+ return;
+ }
+
+ // True iff we're currently in idle (link paused, waiting on the 33 ms
+ // resume timer). In that mode we *do* want input/focus events to kick
+ // an immediate redraw so the cadence transition happens within one
+ // frame. In active mode we explicitly do NOT request_redraw — the
+ // CADisplayLink Vsync user-events are the sole paint trigger, and any
+ // extra request_redraw causes a double-paint that contends with the
+ // compositor and produces a missed-vsync outlier.
+ let is_idle = self.next_idle_wake.is_some();
+
+ // Focus changes drive the idle/active cadence. Gaining focus instantly
+ // restores the full-speed tier; losing focus lets the next paint drop
+ // us straight into idle mode.
+ if let WindowEvent::Focused(focused) = &event {
+ if let Some(app) = self.app.as_mut() {
+ app.set_focused(*focused);
+ }
+ if *focused && is_idle {
+ if let Some(gw) = self.gl_window.as_ref() {
+ gw.window().request_redraw();
+ }
+ }
+ }
+
+ // Any user input bumps the activity timer (keeps us in full-speed mode
+ // for `idle_grace`). Only kick a redraw if we're idle right now.
+ let is_input = matches!(
+ event,
+ WindowEvent::CursorMoved { .. }
+ | WindowEvent::CursorEntered { .. }
+ | WindowEvent::MouseInput { .. }
+ | WindowEvent::MouseWheel { .. }
+ | WindowEvent::KeyboardInput { .. }
+ | WindowEvent::ModifiersChanged(..)
+ | WindowEvent::Ime(..)
+ | WindowEvent::Touch(..)
+ | WindowEvent::PinchGesture { .. }
+ | WindowEvent::PanGesture { .. }
+ | WindowEvent::RotationGesture { .. }
+ );
+ if is_input {
+ if let Some(app) = self.app.as_mut() {
+ app.touch_input();
+ }
+ if is_idle {
+ if let Some(gw) = self.gl_window.as_ref() {
+ gw.window().request_redraw();
+ }
+ }
+ }
+
+ // Feed the event to egui_winit so it can update its input state. We
+ // *deliberately* ignore `event_response.repaint` — the whole point of
+ // this rewrite is to decouple our redraw cadence from input arrival.
+ // Display vsync ticks are the only signal that triggers a paint.
+ if let (Some(eg), Some(gw)) = (self.egui_glow.as_mut(), self.gl_window.as_ref()) {
+ let _ = eg.on_window_event(gw.window(), &event);
+ }
+ }
+
+ fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
+ match event {
+ UserEvent::Vsync => {
+ // Paint *directly* — skip the request_redraw → RedrawRequested
+ // round-trip through winit's queue. That extra hop costs us
+ // some latency on every frame and, when we're close to the
+ // vsync deadline, occasionally pushes us into the next vsync
+ // window and produces a 8–12 ms outlier (visible micro-stutter).
+ self.redraw(event_loop);
+ }
+ }
+ }
+
+ fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) {
+ if let winit::event::StartCause::ResumeTimeReached { .. } = cause {
+ if let Some(gw) = self.gl_window.as_ref() {
+ gw.window().request_redraw();
+ }
+ self.next_idle_wake = None;
+ }
+ }
+
+ fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
+ if let Some(eg) = self.egui_glow.as_mut() {
+ eg.destroy();
+ }
+ }
+}
+
+impl AppState {
+ fn redraw(&mut self, event_loop: &ActiveEventLoop) {
+ let now = Instant::now();
+
+ // Coalesce duplicate redraw triggers. At 240 Hz the vsync window is
+ // ~4.17 ms; a paint that lands within half of that of the previous
+ // paint is a duplicate (e.g. a Vsync arrived microseconds after a
+ // RedrawRequested from the same vsync, or two redraw paths fired in
+ // rapid succession). Painting twice in the same compositor window
+ // contends for the same presentation slot and shows up as an 8–12 ms
+ // worst-case frame in the FPS log even when avg is locked at 4.17.
+ if let Some(prev) = self.last_paint_at {
+ if now.duration_since(prev) < Duration::from_micros(2_000) {
+ return;
+ }
+ }
+
+ // Frame timing
+ let dt = now.duration_since(self.last_frame).as_secs_f32().min(0.05);
+ self.last_frame = now;
+ self.frame_times.push_back(dt);
+ while self.frame_times.iter().sum::<f32>() > 1.0 && self.frame_times.len() > 2 {
+ self.frame_times.pop_front();
+ }
+
+ let needs_full_speed = match self.app.as_ref() {
+ Some(a) => a.needs_full_speed(),
+ None => false,
+ };
+
+ // Toggle the display link based on app state. Active = link runs at
+ // 240 Hz and pumps Vsync events; Idle = link paused, OS drops the
+ // refresh tier, we use a 33 ms timer for the next wake-up.
+ #[cfg(target_os = "macos")]
+ macos_display::set_active(needs_full_speed);
+ if !needs_full_speed {
+ let next = now + Duration::from_millis(33);
+ self.next_idle_wake = Some(next);
+ event_loop.set_control_flow(ControlFlow::WaitUntil(next));
+ } else {
+ // While active, control flow stays Wait — we paint only when a
+ // Vsync user-event arrives. No polling, no busy-looping. Clear
+ // `next_idle_wake` so the input/focus handlers in window_event
+ // know we're no longer in the idle path and don't fire extra
+ // request_redraw calls (which would cause double-paints).
+ self.next_idle_wake = None;
+ event_loop.set_control_flow(ControlFlow::Wait);
+ }
+
+ // Run the egui frame.
+ let gw = self.gl_window.as_mut().expect("gl_window");
+ let eg = self.egui_glow.as_mut().expect("egui_glow");
+ let app = self.app.as_mut().expect("app");
+
+ eg.run(gw.window(), |ctx| {
+ app.update(ctx);
+ });
+
+ // Paint.
+ let theme_bg = app.bg_color();
+ unsafe {
+ use glow::HasContext as _;
+ let gl = self.gl.as_ref().expect("gl");
+ gl.clear_color(theme_bg[0], theme_bg[1], theme_bg[2], 1.0);
+ gl.clear(glow::COLOR_BUFFER_BIT);
+ }
+ eg.paint(gw.window());
+ let _ = gw.swap_buffers();
+ self.last_paint_at = Some(now);
+
+ // Diagnostics
+ if std::env::var_os("NAVI_FPS_LOG").is_some() {
+ let due = self
+ .last_fps_log
+ .map_or(true, |t| t.elapsed() >= Duration::from_secs(1));
+ if due && self.frame_times.len() >= 2 {
+ let n = self.frame_times.len() as f32;
+ let total: f32 = self.frame_times.iter().sum();
+ let avg_ms = total / n * 1000.0;
+ let max_ms = self
+ .frame_times
+ .iter()
+ .copied()
+ .fold(0.0_f32, f32::max)
+ * 1000.0;
+ let fps = n / total;
+ #[cfg(target_os = "macos")]
+ let link_info = {
+ let ticks = macos_display::tick_count();
+ let interval_us = macos_display::vsync_interval()
+ .map(|d| d.as_micros() as u64)
+ .unwrap_or(0);
+ let hz = if interval_us > 0 {
+ 1_000_000.0 / interval_us as f64
+ } else {
+ 0.0
+ };
+ format!(" link_total_ticks={ticks} link_interval={interval_us}us ({hz:.0}Hz)")
+ };
+ #[cfg(not(target_os = "macos"))]
+ let link_info = String::new();
+ eprintln!(
+ "navi: fps={:.1} avg={:.2}ms worst={:.2}ms frames={}{}",
+ fps,
+ avg_ms,
+ max_ms,
+ self.frame_times.len(),
+ link_info,
+ );
+ self.last_fps_log = Some(now);
+ }
+ }
+ }
+}
+
+// ─── Glutin context plumbing ───────────────────────────────────────────────────
+
+struct GlutinWindow {
+ window: Window,
+ gl_context: glutin::context::PossiblyCurrentContext,
+ gl_display: glutin::display::Display,
+ gl_surface: glutin::surface::Surface<glutin::surface::WindowSurface>,
+}
+
+impl GlutinWindow {
+ /// Construct the window + GL context. Adapted from egui_glow's pure_glow example.
+ /// Vsync is left ON (`SwapInterval::Wait(1)`) — it's harmless at 0.5 ms render
+ /// time and gives the OS a clean signal that we want display sync.
+ unsafe fn create(event_loop: &ActiveEventLoop) -> (Self, glow::Context) {
+ let window_attrs = winit::window::WindowAttributes::default()
+ .with_resizable(true)
+ .with_inner_size(winit::dpi::LogicalSize::new(1400.0, 900.0))
+ .with_min_inner_size(winit::dpi::LogicalSize::new(400.0, 300.0))
+ .with_title("Navi")
+ .with_visible(false);
+
+ let cfg_template = glutin::config::ConfigTemplateBuilder::new()
+ .prefer_hardware_accelerated(None)
+ .with_depth_size(0)
+ .with_stencil_size(0)
+ .with_transparency(false);
+
+ let (mut window_opt, gl_config) = glutin_winit::DisplayBuilder::new()
+ .with_preference(glutin_winit::ApiPreference::FallbackEgl)
+ .with_window_attributes(Some(window_attrs.clone()))
+ .build(event_loop, cfg_template, |mut it| {
+ it.next().expect("no matching gl config")
+ })
+ .expect("failed to create gl_config");
+
+ let gl_display = gl_config.display();
+ let raw_window_handle = window_opt
+ .as_ref()
+ .map(|w| w.window_handle().expect("window handle").as_raw());
+
+ let context_attrs =
+ glutin::context::ContextAttributesBuilder::new().build(raw_window_handle);
+ let fallback_attrs = glutin::context::ContextAttributesBuilder::new()
+ .with_context_api(glutin::context::ContextApi::Gles(None))
+ .build(raw_window_handle);
+
+ let not_current = unsafe {
+ gl_display
+ .create_context(&gl_config, &context_attrs)
+ .unwrap_or_else(|_| {
+ gl_display
+ .create_context(&gl_config, &fallback_attrs)
+ .expect("failed to create gl context")
+ })
+ };
+
+ let window = window_opt.take().unwrap_or_else(|| {
+ glutin_winit::finalize_window(event_loop, window_attrs.clone(), &gl_config)
+ .expect("failed to finalize window")
+ });
+
+ let (w, h): (u32, u32) = window.inner_size().into();
+ let surface_attrs =
+ glutin::surface::SurfaceAttributesBuilder::<glutin::surface::WindowSurface>::new()
+ .build(
+ window.window_handle().expect("window handle").as_raw(),
+ NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN),
+ NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN),
+ );
+
+ let gl_surface = unsafe {
+ gl_display
+ .create_window_surface(&gl_config, &surface_attrs)
+ .expect("failed to create surface")
+ };
+ let gl_context = not_current
+ .make_current(&gl_surface)
+ .expect("failed to make context current");
+
+ // Vsync OFF. macOS OpenGL's swap-vsync is capped at ~108 Hz on ProMotion
+ // panels regardless of what tier the display is actually running at, so
+ // letting it block here would clamp us to 108 fps even though the display
+ // link is firing at 240 Hz. Pacing is owned entirely by the bg-thread
+ // display link in macos_display.rs.
+ let _ = gl_surface.set_swap_interval(&gl_context, glutin::surface::SwapInterval::DontWait);
+
+ let gl = unsafe {
+ glow::Context::from_loader_function(|s| {
+ let cstr = std::ffi::CString::new(s).unwrap();
+ gl_display.get_proc_address(&cstr)
+ })
+ };
+
+ (
+ Self {
+ window,
+ gl_context,
+ gl_display,
+ gl_surface,
+ },
+ gl,
+ )
+ }
+
+ fn window(&self) -> &Window {
+ &self.window
+ }
+
+ fn resize(&self, size: winit::dpi::PhysicalSize<u32>) {
+ let _ = &self.gl_display; // silence "unused"
+ self.gl_surface.resize(
+ &self.gl_context,
+ NonZeroU32::new(size.width).unwrap_or(NonZeroU32::MIN),
+ NonZeroU32::new(size.height).unwrap_or(NonZeroU32::MIN),
+ );
+ }
+
+ fn swap_buffers(&self) -> glutin::error::Result<()> {
+ self.gl_surface.swap_buffers(&self.gl_context)
+ }
+}
+
+// ─── Fonts ────────────────────────────────────────────────────────────────────
+
+/// Load a prioritised font fallback chain so egui can render:
+/// • Nerd Font symbols (powerline, devicons, file icons) — Meslo NF
+/// • Japanese / CJK — Noto Sans JP
+/// • Broad Unicode catch-all (~50 k glyphs) — Arial Unicode
+/// • Extra maths / misc symbols — Noto Sans Symbols 2
+fn setup_fonts(ctx: &egui::Context) {
+ let home = std::env::var("HOME").unwrap_or_default();
+ let candidates: &[(&str, String)] = &[
+ ("nerd", format!("{home}/Library/Fonts/MesloLGLNerdFont-Regular.ttf")),
+ ("jp", "/Library/Fonts/NotoSansJP-Regular.otf".into()),
+ ("unicode", "/Library/Fonts/Arial Unicode.ttf".into()),
+ ("symbols2", "/Library/Fonts/NotoSansSymbols2-Regular.ttf".into()),
+ ];
+ let mut fonts = egui::FontDefinitions::default();
+ for (name, path) in candidates {
+ if let Ok(data) = std::fs::read(path) {
+ fonts
+ .font_data
+ .insert((*name).to_string(), egui::FontData::from_owned(data));
+ for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
+ fonts
+ .families
+ .entry(family)
+ .or_default()
+ .push((*name).to_string());
+ }
+ }
+ }
+ ctx.set_fonts(fonts);
+}
diff --git a/navi/src/painter.rs b/navi/src/painter.rs
@@ -0,0 +1,303 @@
+use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, epaint::FontId};
+use navi_core::{Graph, Node};
+use std::collections::HashSet;
+use crate::theme::Theme;
+
+// ── Age ───────────────────────────────────────────────────────────────────────
+
+pub fn age_days(mtime: i64, now_secs: f64) -> f64 {
+ ((now_secs - mtime as f64) / 86400.0).max(0.0)
+}
+
+pub fn age_stage(mtime: i64, now_secs: f64) -> usize {
+ const BREAKS: &[f64] = &[7.0, 30.0, 90.0, 270.0, 540.0];
+ let d = age_days(mtime, now_secs);
+ for (i, &t) in BREAKS.iter().enumerate() { if d <= t { return i; } }
+ BREAKS.len()
+}
+
+
+// Continuous age parameter: 0.0 = just created, 1.0 = fully settled.
+// Exponential approach with ~180-day half-life so notes feel fresh for weeks,
+// visibly settled over months, deeply integrated past a year.
+fn compute_age_t(mtime: i64, now_secs: f64) -> f32 {
+ let days = age_days(mtime, now_secs) as f32;
+ 1.0 - (-days / 180.0).exp()
+}
+
+// ── Color helpers ─────────────────────────────────────────────────────────────
+
+fn lerp_col(a: Color32, b: Color32, t: f32) -> Color32 {
+ let t = t.clamp(0.0, 1.0);
+ Color32::from_rgb(
+ (a.r() as f32 + (b.r() as f32 - a.r() as f32) * t).round() as u8,
+ (a.g() as f32 + (b.g() as f32 - a.g() as f32) * t).round() as u8,
+ (a.b() as f32 + (b.b() as f32 - a.b() as f32) * t).round() as u8,
+ )
+}
+
+// C: Temperature drift.
+// Fresh end: 18% blend toward the theme's warm accent (node_sel — amber or cyan
+// complement), so the colour reads as "active/present" in the palette's own language.
+// Settled end: 40% blend toward the background, so old nodes visibly sink into
+// the canvas — clearly darker, clearly cooler, clearly integrated.
+fn age_color(base: Color32, bg: Color32, warm_accent: Color32, t: f32) -> Color32 {
+ if t < 0.35 {
+ // Fresh zone: warm-accent tint fades back to true colour
+ let u = t / 0.35;
+ lerp_col(lerp_col(base, warm_accent, 0.18), base, u)
+ } else {
+ // Settled zone: true colour drifts toward background (max 40%)
+ lerp_col(base, bg, 0.40 * ((t - 0.35) / 0.65))
+ }
+}
+
+fn brighten(c: Color32, amount: f32) -> Color32 {
+ Color32::from_rgb(
+ (c.r() as f32 + (255.0 - c.r() as f32) * amount) as u8,
+ (c.g() as f32 + (255.0 - c.g() as f32) * amount) as u8,
+ (c.b() as f32 + (255.0 - c.b() as f32) * amount) as u8,
+ )
+}
+
+fn dim(c: Color32, amt: u8) -> Color32 {
+ Color32::from_rgb(
+ c.r().saturating_sub(amt),
+ c.g().saturating_sub(amt),
+ c.b().saturating_sub(amt),
+ )
+}
+
+// ── Painter ───────────────────────────────────────────────────────────────────
+
+pub struct GraphPainter<'a> {
+ pub graph: &'a Graph,
+ pub theme: Theme,
+ pub pan: Vec2,
+ pub zoom: f32,
+ pub hovered: Option<&'a str>,
+ pub selected: Option<&'a str>,
+ pub hidden: &'a HashSet<String>,
+ pub faded: &'a HashSet<String>,
+ pub hov_adj: HashSet<String>,
+ pub show_tags: bool,
+ pub show_age: bool,
+ pub now_secs: f64,
+ pub graph_rect: Rect,
+}
+
+impl<'a> GraphPainter<'a> {
+ pub fn w2s(&self, wx: f64, wy: f64) -> Pos2 {
+ let cx = self.graph_rect.left() + self.graph_rect.width() / 2.0;
+ let cy = self.graph_rect.top() + self.graph_rect.height() / 2.0;
+ Pos2::new(cx + (wx as f32) * self.zoom + self.pan.x,
+ cy + (wy as f32) * self.zoom + self.pan.y)
+ }
+
+ pub fn s2w(&self, sx: f32, sy: f32) -> (f64, f64) {
+ let cx = self.graph_rect.left() + self.graph_rect.width() / 2.0;
+ let cy = self.graph_rect.top() + self.graph_rect.height() / 2.0;
+ (((sx - cx - self.pan.x) / self.zoom) as f64,
+ ((sy - cy - self.pan.y) / self.zoom) as f64)
+ }
+
+ fn node_base_colors(&self, nd: &Node) -> (Color32, Color32) {
+ let t = &self.theme;
+ if self.show_tags && !nd.tags.is_empty() {
+ if let Some(&tc) = self.graph.tag_colors.get(&nd.tags[0]) {
+ let body = Color32::from_rgb(tc[0], tc[1], tc[2]);
+ let rim = Color32::from_rgb(
+ (tc[0] as u16 + 38).min(255) as u8,
+ (tc[1] as u16 + 38).min(255) as u8,
+ (tc[2] as u16 + 38).min(255) as u8,
+ );
+ return (body, rim);
+ }
+ }
+ (t.node, t.node_rim)
+ }
+
+ fn node_colors(&self, nd: &Node, is_sel: bool, is_hov: bool, is_fade: bool) -> (Color32, Color32) {
+ let t = &self.theme;
+ if is_hov && !is_sel { return (t.node_rim, t.node_hov); }
+ let (body, rim) = self.node_base_colors(nd);
+ if is_fade { return (dim(rim, 70), dim(body, 70)); }
+ (rim, body)
+ }
+
+ pub fn paint_grid(&self, painter: &Painter) {
+ let t = &self.theme;
+ let rect = self.graph_rect;
+ let col = Color32::from_rgba_unmultiplied(t.grid.r(), t.grid.g(), t.grid.b(), 200);
+ let sp = (12.0 * self.zoom).max(3.0);
+ let cx = rect.left() + rect.width() / 2.0 + self.pan.x;
+ let cy = rect.top() + rect.height() / 2.0 + self.pan.y;
+ let ox = ((cx % sp) + sp) % sp;
+ let oy = ((cy % sp) + sp) % sp;
+ let s = Stroke::new(0.5, col);
+ let mut x = rect.left() + ox - sp;
+ while x <= rect.right() + sp {
+ painter.line_segment([Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], s);
+ x += sp;
+ }
+ let mut y = rect.top() + oy - sp;
+ while y <= rect.bottom() + sp {
+ painter.line_segment([Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)], s);
+ y += sp;
+ }
+ }
+
+ pub fn paint_edges(&self, painter: &Painter) {
+ let t = &self.theme;
+ let edge_col = Color32::from_rgba_unmultiplied(t.edge.r(), t.edge.g(), t.edge.b(), 190);
+ let edge_hi = Color32::from_rgba_unmultiplied(t.edge_hi.r(), t.edge_hi.g(), t.edge_hi.b(), 255);
+ for &(si, di) in &self.graph.edges {
+ let a = &self.graph.node_list[si];
+ let b = &self.graph.node_list[di];
+ if self.hidden.contains(&a.id) || self.hidden.contains(&b.id) { continue; }
+ if self.faded.contains(&a.id) && self.faded.contains(&b.id) { continue; }
+ let p1 = self.w2s(a.x, a.y);
+ let p2 = self.w2s(b.x, b.y);
+ let r = self.graph_rect;
+ if p1.x < r.left()-10.0 && p2.x < r.left()-10.0 { continue; }
+ if p1.x > r.right()+10.0 && p2.x > r.right()+10.0 { continue; }
+ if p1.y < r.top()-10.0 && p2.y < r.top()-10.0 { continue; }
+ if p1.y > r.bottom()+10.0 && p2.y > r.bottom()+10.0 { continue; }
+ let hov = self.hov_adj.contains(&a.id) && self.hov_adj.contains(&b.id)
+ || (Some(a.id.as_str()) == self.hovered && self.hov_adj.contains(&b.id))
+ || (Some(b.id.as_str()) == self.hovered && self.hov_adj.contains(&a.id));
+ painter.line_segment([p1, p2], Stroke::new(1.0, if hov { edge_hi } else { edge_col }));
+ }
+ }
+
+ pub fn paint_nodes(&self, painter: &Painter) {
+ for (node_idx, nd) in self.graph.node_list.iter().enumerate() {
+ if self.hidden.contains(&nd.id) { continue; }
+ let sc = self.w2s(nd.x, nd.y);
+ // Visual breathing — purely cosmetic screen-space offset, actual world
+ // position (used for hit detection, edges, physics) is unchanged.
+ let phase = node_idx as f32 * 1.618033988749895_f32;
+ let bt = self.now_secs as f32;
+ let dsc = sc + Vec2::new(
+ (bt * 0.32 + phase).sin() * 4.0,
+ (bt * 0.27 + phase * 1.272).cos() * 4.0,
+ );
+ let pr = (nd.radius * self.zoom).max(2.0);
+ let r = self.graph_rect;
+ if sc.x + pr*4.0 < r.left()-10.0 || sc.x - pr*4.0 > r.right()+10.0 { continue; }
+ if sc.y + pr*4.0 < r.top()-10.0 || sc.y - pr*4.0 > r.bottom()+10.0 { continue; }
+
+ let is_sel = Some(nd.id.as_str()) == self.selected;
+ let is_hov = Some(nd.id.as_str()) == self.hovered;
+ let is_fade = self.faded.contains(&nd.id)
+ || (self.hovered.is_some() && !self.hov_adj.contains(&nd.id) && !is_sel && !is_hov);
+
+ let (rim_col, body_col) = self.node_colors(nd, is_sel, is_hov, is_fade);
+
+ // Age parameter — bypassed for selected/hovered so they always read true colour
+ let age_t = if !is_sel && !is_hov && self.show_age && nd.mtime > 0 {
+ compute_age_t(nd.mtime, self.now_secs)
+ } else {
+ 0.0_f32
+ };
+
+ // C: temperature drift — warm accent tint when fresh, sinks toward bg when settled
+ let wa = self.theme.node_sel; // warm accent (amber, cyan, etc. per theme)
+ let body = age_color(body_col, self.theme.bg, wa, age_t);
+ let rim = age_color(rim_col, self.theme.bg, wa, age_t);
+
+ // Selection ring — tight accent just outside the node
+ if is_sel {
+ let sc = self.theme.node_selr;
+ painter.circle_stroke(dsc, pr * 1.28, Stroke::new(1.8,
+ Color32::from_rgba_unmultiplied(sc.r(), sc.g(), sc.b(), 195)));
+ }
+
+ // Glow fades with age so settled nodes don't radiate
+ let glow_base = if is_fade { 3u8 } else { 9 };
+ let glow_a = (glow_base as f32 * (1.0 - age_t * 0.75)) as u8;
+ painter.circle_filled(dsc, pr * 1.18,
+ Color32::from_rgba_unmultiplied(rim.r(), rim.g(), rim.b(), glow_a));
+
+ painter.circle_filled(dsc, pr, body);
+
+ // D: rim softness — crisp when fresh, dissolves as node settles
+ let rim_a = (255.0 * (1.0 - age_t).powf(0.55)) as u8;
+ let rim_w = (pr * 0.25).max(1.0).min(3.0) * (1.0 - age_t * 0.40).max(0.0);
+ if rim_a > 4 && rim_w > 0.3 {
+ painter.circle_stroke(dsc, pr, Stroke::new(rim_w,
+ Color32::from_rgba_unmultiplied(rim.r(), rim.g(), rim.b(), rim_a)));
+ }
+
+ // F: highlight depth — present surface when fresh, flattens with age
+ if pr >= 5.0 {
+ let hl_a = (28.0 * (1.0 - age_t).powf(0.45)) as u8;
+ if hl_a > 2 {
+ painter.circle_filled(
+ dsc + Vec2::new(-pr * 0.27, -pr * 0.27),
+ (pr * 0.28).max(1.0),
+ Color32::from_rgba_unmultiplied(255, 255, 255, hl_a),
+ );
+ }
+ }
+
+ // Inner ring for headline nodes
+ if nd.level > 0 && pr >= 5.0 {
+ painter.circle_stroke(dsc, pr * 0.33, Stroke::new(1.0, rim));
+ }
+ }
+ }
+
+ pub fn paint_labels(&self, painter: &Painter) {
+ let t = &self.theme;
+ if self.zoom < 0.25 { return; }
+ let alpha_base = (((self.zoom - 0.25) / 0.35) * 255.0).min(255.0) as u8;
+ let font = FontId::proportional(13.0);
+ // Adaptive truncation: long titles overlap at low zoom
+ let max_chars: usize = if self.zoom < 0.55 { 8 }
+ else if self.zoom < 1.0 { 15 }
+ else { usize::MAX };
+ for (node_idx, nd) in self.graph.node_list.iter().enumerate() {
+ if self.hidden.contains(&nd.id) { continue; }
+ let sc = self.w2s(nd.x, nd.y);
+ // Match breathing offset so label stays under its node
+ let phase = node_idx as f32 * 1.618033988749895_f32;
+ let bt = self.now_secs as f32;
+ let dsc = sc + Vec2::new(
+ (bt * 0.32 + phase).sin() * 4.0,
+ (bt * 0.27 + phase * 1.272).cos() * 4.0,
+ );
+ let r = self.graph_rect;
+ if dsc.x < r.left()-200.0 || dsc.x > r.right()+200.0 { continue; }
+ if dsc.y < r.top()-20.0 || dsc.y > r.bottom()+20.0 { continue; }
+ let is_hi = Some(nd.id.as_str()) == self.hovered || Some(nd.id.as_str()) == self.selected;
+ let is_fade = self.faded.contains(&nd.id)
+ || (self.hovered.is_some() && !self.hov_adj.contains(&nd.id) && !is_hi);
+ let alpha = if is_fade { (alpha_base / 5).max(0) } else { alpha_base };
+ if alpha < 5 { continue; }
+ let base_col = if is_hi { t.label_hov } else { t.label };
+ let col = Color32::from_rgba_unmultiplied(base_col.r(), base_col.g(), base_col.b(), alpha);
+ let pr = (nd.radius * self.zoom).max(2.0);
+ let title = if nd.title.chars().count() > max_chars {
+ nd.title.chars().take(max_chars).collect::<String>() + "…"
+ } else {
+ nd.title.clone()
+ };
+ painter.text(Pos2::new(dsc.x, dsc.y + pr + 8.0),
+ egui::Align2::CENTER_TOP, &title, font.clone(), col);
+ }
+ }
+
+ pub fn node_at(&self, sx: f32, sy: f32) -> Option<&str> {
+ let mut best_d = f32::INFINITY;
+ let mut best: Option<&str> = None;
+ for nd in &self.graph.node_list {
+ if self.hidden.contains(&nd.id) { continue; }
+ let sc = self.w2s(nd.x, nd.y);
+ let d = (sc - Pos2::new(sx, sy)).length();
+ let hr = (nd.radius * self.zoom).max(9.0);
+ if d <= hr && d < best_d { best_d = d; best = Some(&nd.id); }
+ }
+ best
+ }
+}
diff --git a/navi/src/theme.rs b/navi/src/theme.rs
@@ -0,0 +1,261 @@
+use egui::Color32;
+
+#[derive(Clone, Copy, Debug)]
+pub struct Theme {
+ pub name: &'static str,
+ pub bg: Color32,
+ pub grid: Color32,
+ pub edge: Color32,
+ pub edge_hi: Color32,
+ pub node: Color32,
+ pub node_rim: Color32,
+ pub node_hov: Color32,
+ pub node_sel: Color32,
+ pub node_selr: Color32,
+ pub label: Color32,
+ pub label_hov: Color32,
+ pub bar_bg: Color32,
+ pub bar_line: Color32,
+ pub bar_text: Color32,
+}
+
+impl Theme {
+ pub fn glow(&self) -> Color32 {
+ let c = self.node;
+ Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 28)
+ }
+}
+
+// ── Navi palette ──────────────────────────────────────────────────────────────
+// Source: macro photo of a black butterfly on vivid blue salvia flowers with a
+// glowing cyan bokeh orb. All five Navi variants share the same background
+// architecture, bar, labels, and amber complement anchor. Only nodes, edges,
+// and highlights differ — each drawn from a different family in the palette.
+
+// Shared background infrastructure (same across all Navi variants)
+// bg #101e2e Deep Night Blue (btop main_bg)
+// grid #192e48 Dark Cerulean (btop meter_bg — one luminance step up)
+// bar_bg #0a1c34 Oxford Blue (btop inactive_bg)
+// bar_line #265080 Payne's Grey blue (btop div_line)
+// bar_text #a8c8d8 Powder Blue (btop graph_text)
+// label #7898b8 Cadet Grey (btop inactive_fg)
+// label_hov#e0f0ff Alice Blue (btop main_fg)
+// node_sel #f09030 Amber (complement anchor — the one warm hue)
+
+pub const THEMES: &[Theme] = &[
+ // ── 0 · Salvia ────────────────────────────────────────────────────────────
+ // Periwinkle nodes / Electric Cyan ghost-light rim.
+ // The salvia flowers (electric periwinkle #5578ff) meet the bokeh orb
+ // (#40e8ff) — the signature Navi look.
+ Theme {
+ name: "Salvia",
+ bg: Color32::from_rgb( 16, 30, 46), // #101e2e Deep Night Blue
+ grid: Color32::from_rgb( 25, 46, 72), // #192e48 Dark Cerulean
+ edge: Color32::from_rgb( 38, 80, 128), // #265080 Payne's Grey blue
+ edge_hi: Color32::from_rgb( 64, 232, 255), // #40e8ff Electric Cyan
+ node: Color32::from_rgb( 85, 120, 255), // #5578ff Electric Periwinkle
+ node_rim: Color32::from_rgb( 64, 232, 255), // #40e8ff Electric Cyan
+ node_hov: Color32::from_rgb( 0, 216, 248), // #00d8f8 Vivid Cerulean
+ node_sel: Color32::from_rgb(240, 144, 48), // #f09030 Amber
+ node_selr: Color32::from_rgb(156, 242, 255), // #9cf2ff soft cyan — rim brightened
+ label: Color32::from_rgb(120, 152, 184), // #7898b8 Cadet Grey
+ label_hov: Color32::from_rgb(224, 240, 255), // #e0f0ff Alice Blue
+ bar_bg: Color32::from_rgb( 10, 28, 52), // #0a1c34 Oxford Blue
+ bar_line: Color32::from_rgb( 38, 80, 128), // #265080 Payne's Grey blue
+ bar_text: Color32::from_rgb(168, 200, 216), // #a8c8d8 Powder Blue
+ },
+ // ── 1 · Bokeh ─────────────────────────────────────────────────────────────
+ // Pure cyan family — the glowing bokeh orb made into nodes.
+ // Pacific Blue nodes (#00b8d8) ringed in Electric Cyan (#40e8ff).
+ // Edges pull from Deep Teal (#023040), the darkest cyan bg in the palette.
+ Theme {
+ name: "Bokeh",
+ bg: Color32::from_rgb( 16, 30, 46), // #101e2e
+ grid: Color32::from_rgb( 25, 46, 72), // #192e48
+ edge: Color32::from_rgb( 26, 96, 112), // #1a6070 mid teal
+ edge_hi: Color32::from_rgb( 64, 232, 255), // #40e8ff Electric Cyan
+ node: Color32::from_rgb( 0, 184, 216), // #00b8d8 Pacific Blue
+ node_rim: Color32::from_rgb( 64, 232, 255), // #40e8ff Electric Cyan
+ node_hov: Color32::from_rgb( 0, 216, 248), // #00d8f8 Vivid Cerulean
+ node_sel: Color32::from_rgb(240, 144, 48), // #f09030 Amber
+ node_selr: Color32::from_rgb(156, 242, 255), // #9cf2ff soft cyan — rim brightened
+ label: Color32::from_rgb(120, 152, 184), // #7898b8 Cadet Grey
+ label_hov: Color32::from_rgb(224, 240, 255), // #e0f0ff Alice Blue
+ bar_bg: Color32::from_rgb( 10, 28, 52), // #0a1c34 Oxford Blue
+ bar_line: Color32::from_rgb( 38, 80, 128), // #265080 Payne's Grey blue
+ bar_text: Color32::from_rgb(168, 200, 216), // #a8c8d8 Powder Blue
+ },
+ // ── 2 · Stem ──────────────────────────────────────────────────────────────
+ // The cool stem greens beneath the salvia flowers.
+ // Caribbean Green nodes (#00e898) / Aquamarine rim (#00f0b0).
+ // Edges from Hunter Green (#052818) — darkest green bg in the palette.
+ Theme {
+ name: "Stem",
+ bg: Color32::from_rgb( 16, 30, 46), // #101e2e
+ grid: Color32::from_rgb( 25, 46, 72), // #192e48
+ edge: Color32::from_rgb( 20, 100, 56), // #146438 mid green
+ edge_hi: Color32::from_rgb( 0, 240, 176), // #00f0b0 Aquamarine
+ node: Color32::from_rgb( 0, 232, 152), // #00e898 Caribbean Green
+ node_rim: Color32::from_rgb( 0, 240, 176), // #00f0b0 Aquamarine
+ node_hov: Color32::from_rgb( 64, 216, 112), // #40d870 Malachite
+ node_sel: Color32::from_rgb(240, 144, 48), // #f09030 Amber
+ node_selr: Color32::from_rgb(128, 248, 216), // #80f8d8 soft aqua — rim brightened
+ label: Color32::from_rgb(120, 152, 184), // #7898b8 Cadet Grey
+ label_hov: Color32::from_rgb(224, 240, 255), // #e0f0ff Alice Blue
+ bar_bg: Color32::from_rgb( 10, 28, 52), // #0a1c34 Oxford Blue
+ bar_line: Color32::from_rgb( 38, 80, 128), // #265080 Payne's Grey blue
+ bar_text: Color32::from_rgb(168, 200, 216), // #a8c8d8 Powder Blue
+ },
+ // ── 3 · Petal ─────────────────────────────────────────────────────────────
+ // The iridescent wing of the black butterfly — violet and magenta.
+ // Amethyst nodes (#a868d8) / Electric Violet rim (#c040f8).
+ // Edges from Violet Black (#1e0a48) — darkest magenta bg in the palette.
+ Theme {
+ name: "Petal",
+ bg: Color32::from_rgb( 16, 30, 46), // #101e2e
+ grid: Color32::from_rgb( 25, 46, 72), // #192e48
+ edge: Color32::from_rgb( 72, 24, 144), // #481890 Dark Indigo
+ edge_hi: Color32::from_rgb(224, 48, 255), // #e030ff Electric Magenta
+ node: Color32::from_rgb(168, 104, 216), // #a868d8 Amethyst
+ node_rim: Color32::from_rgb(192, 64, 248), // #c040f8 Electric Violet
+ node_hov: Color32::from_rgb(224, 48, 255), // #e030ff Electric Magenta
+ node_sel: Color32::from_rgb(240, 144, 48), // #f09030 Amber
+ node_selr: Color32::from_rgb(223, 159, 251), // #df9ffb soft lavender — rim brightened
+ label: Color32::from_rgb(120, 152, 184), // #7898b8 Cadet Grey
+ label_hov: Color32::from_rgb(224, 240, 255), // #e0f0ff Alice Blue
+ bar_bg: Color32::from_rgb( 10, 28, 52), // #0a1c34 Oxford Blue
+ bar_line: Color32::from_rgb( 38, 80, 128), // #265080 Payne's Grey blue
+ bar_text: Color32::from_rgb(168, 200, 216), // #a8c8d8 Powder Blue
+ },
+ // ── 4 · Azure ─────────────────────────────────────────────────────────────
+ // The deeper blue-cooler family — calmer than Salvia, still fully Navi.
+ // Cobalt Blue nodes (#3d7fff) / Azure Blue rim (#1a9aff).
+ // Edges from Midnight Indigo (#082060).
+ Theme {
+ name: "Azure",
+ bg: Color32::from_rgb( 16, 30, 46), // #101e2e
+ grid: Color32::from_rgb( 25, 46, 72), // #192e48
+ edge: Color32::from_rgb( 24, 56, 160), // #1838a0 Ultramarine
+ edge_hi: Color32::from_rgb( 26, 154, 255), // #1a9aff Azure Blue
+ node: Color32::from_rgb( 61, 127, 255), // #3d7fff Cobalt Blue
+ node_rim: Color32::from_rgb( 26, 154, 255), // #1a9aff Azure Blue
+ node_hov: Color32::from_rgb( 64, 232, 255), // #40e8ff Electric Cyan
+ node_sel: Color32::from_rgb(240, 144, 48), // #f09030 Amber
+ node_selr: Color32::from_rgb(140, 204, 255), // #8cccff soft sky blue — rim brightened
+ label: Color32::from_rgb(120, 152, 184), // #7898b8 Cadet Grey
+ label_hov: Color32::from_rgb(224, 240, 255), // #e0f0ff Alice Blue
+ bar_bg: Color32::from_rgb( 10, 28, 52), // #0a1c34 Oxford Blue
+ bar_line: Color32::from_rgb( 38, 80, 128), // #265080 Payne's Grey blue
+ bar_text: Color32::from_rgb(168, 200, 216), // #a8c8d8 Powder Blue
+ },
+ // ── 5 · Ruby ──────────────────────────────────────────────────────────────
+ // Fully independent background — wine-dark near-black (#1a0810) so the
+ // crimson nodes feel emitted rather than placed. Cool-shifted ruby crimson
+ // (192, 24, 64) follows the palette rule: reds bent toward blue.
+ // Rim: Flamingo (#f85888). Connections: Claret (#6a1018).
+ // Selection flips the complement — Electric Cyan (#40e8ff) glows against
+ // warm red the same way amber glows against cool blue in the other variants.
+ Theme {
+ name: "Ruby",
+ bg: Color32::from_rgb( 26, 8, 16), // #1a0810 wine-dark near-black
+ grid: Color32::from_rgb( 56, 16, 28), // #38101c dark maroon
+ edge: Color32::from_rgb(106, 16, 24), // #6a1018 Claret
+ edge_hi: Color32::from_rgb(255, 96, 96), // #ff6060 Bittersweet
+ node: Color32::from_rgb(192, 24, 64), // deep ruby crimson
+ node_rim: Color32::from_rgb(248, 88, 136), // #f85888 Flamingo
+ node_hov: Color32::from_rgb(255, 96, 96), // #ff6060 Bittersweet
+ node_sel: Color32::from_rgb( 64, 232, 255), // #40e8ff Electric Cyan — cool complement
+ node_selr: Color32::from_rgb(251, 168, 192), // #fba8c0 soft rose — rim brightened
+ label: Color32::from_rgb(184, 136, 152), // warm rose-grey
+ label_hov: Color32::from_rgb(240, 220, 228), // warm near-white
+ bar_bg: Color32::from_rgb( 16, 4, 8), // #100408 near-black
+ bar_line: Color32::from_rgb(106, 16, 24), // #6a1018 Claret
+ bar_text: Color32::from_rgb(200, 120, 120), // #c87878 Dusty Rose
+ },
+ // ── Legacy themes ─────────────────────────────────────────────────────────
+ Theme {
+ name: "Obsidian",
+ bg: Color32::from_rgb( 13, 13, 20),
+ grid: Color32::from_rgb( 48, 48, 75),
+ edge: Color32::from_rgb( 60, 60, 100),
+ edge_hi: Color32::from_rgb(160, 110, 255),
+ node: Color32::from_rgb(124, 77, 255),
+ node_rim: Color32::from_rgb(175, 140, 255),
+ node_hov: Color32::from_rgb(185, 155, 255),
+ node_sel: Color32::from_rgb(255, 200, 40),
+ node_selr: Color32::from_rgb(212, 192, 255), // #d4c0ff soft pale purple — rim brightened
+ label: Color32::from_rgb(185, 180, 215),
+ label_hov: Color32::from_rgb(255, 255, 255),
+ bar_bg: Color32::from_rgb( 18, 18, 30),
+ bar_line: Color32::from_rgb( 48, 48, 75),
+ bar_text: Color32::from_rgb(140, 135, 175),
+ },
+ Theme {
+ name: "Forest",
+ bg: Color32::from_rgb( 8, 18, 10),
+ grid: Color32::from_rgb( 32, 60, 36),
+ edge: Color32::from_rgb( 40, 80, 50),
+ edge_hi: Color32::from_rgb( 80, 220, 100),
+ node: Color32::from_rgb( 50, 180, 80),
+ node_rim: Color32::from_rgb(100, 220, 130),
+ node_hov: Color32::from_rgb( 80, 200, 110),
+ node_sel: Color32::from_rgb(255, 200, 40),
+ node_selr: Color32::from_rgb(176, 238, 192), // #b0eec0 soft mint — rim brightened
+ label: Color32::from_rgb(170, 210, 175),
+ label_hov: Color32::from_rgb(220, 255, 220),
+ bar_bg: Color32::from_rgb( 12, 24, 14),
+ bar_line: Color32::from_rgb( 30, 60, 35),
+ bar_text: Color32::from_rgb(120, 175, 130),
+ },
+ Theme {
+ name: "Ocean",
+ bg: Color32::from_rgb( 8, 14, 28),
+ grid: Color32::from_rgb( 28, 50, 88),
+ edge: Color32::from_rgb( 30, 60, 110),
+ edge_hi: Color32::from_rgb( 60, 150, 255),
+ node: Color32::from_rgb( 40, 130, 220),
+ node_rim: Color32::from_rgb( 80, 170, 250),
+ node_hov: Color32::from_rgb( 60, 155, 240),
+ node_sel: Color32::from_rgb(255, 200, 40),
+ node_selr: Color32::from_rgb(168, 212, 252), // #a8d4fc soft periwinkle — rim brightened
+ label: Color32::from_rgb(160, 190, 225),
+ label_hov: Color32::from_rgb(200, 230, 255),
+ bar_bg: Color32::from_rgb( 12, 20, 38),
+ bar_line: Color32::from_rgb( 28, 50, 90),
+ bar_text: Color32::from_rgb(100, 145, 195),
+ },
+ Theme {
+ name: "Ember",
+ bg: Color32::from_rgb( 20, 10, 8),
+ grid: Color32::from_rgb( 68, 34, 26),
+ edge: Color32::from_rgb(100, 40, 20),
+ edge_hi: Color32::from_rgb(255, 100, 50),
+ node: Color32::from_rgb(220, 80, 40),
+ node_rim: Color32::from_rgb(250, 130, 80),
+ node_hov: Color32::from_rgb(240, 105, 60),
+ node_sel: Color32::from_rgb(255, 240, 40),
+ node_selr: Color32::from_rgb(252, 192, 168), // #fcc0a8 soft peach — rim brightened
+ label: Color32::from_rgb(215, 185, 170),
+ label_hov: Color32::from_rgb(255, 240, 230),
+ bar_bg: Color32::from_rgb( 28, 14, 10),
+ bar_line: Color32::from_rgb( 60, 30, 20),
+ bar_text: Color32::from_rgb(175, 135, 120),
+ },
+ Theme {
+ name: "Mono",
+ bg: Color32::from_rgb( 12, 12, 12),
+ grid: Color32::from_rgb( 50, 50, 50),
+ edge: Color32::from_rgb( 80, 80, 80),
+ edge_hi: Color32::from_rgb(200, 200, 200),
+ node: Color32::from_rgb(170, 170, 170),
+ node_rim: Color32::from_rgb(210, 210, 210),
+ node_hov: Color32::from_rgb(200, 200, 200),
+ node_sel: Color32::from_rgb(255, 200, 40),
+ node_selr: Color32::from_rgb(232, 232, 232), // #e8e8e8 soft silver-white — rim brightened
+ label: Color32::from_rgb(175, 175, 175),
+ label_hov: Color32::from_rgb(240, 240, 240),
+ bar_bg: Color32::from_rgb( 18, 18, 18),
+ bar_line: Color32::from_rgb( 45, 45, 45),
+ bar_text: Color32::from_rgb(130, 130, 130),
+ },
+];