navi

Obsidian-style interactive graph viewer for org-roam — native window, no Emacs package required.
Log | Files | Refs | README

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:
A.gitignore | 12++++++++++++
ACHANGELOG.md | 44++++++++++++++++++++++++++++++++++++++++++++
ACargo.lock | 3102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 3+++
AREADME.md | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi-core/Cargo.toml | 12++++++++++++
Anavi-core/src/config.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi-core/src/db.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi-core/src/emacs.rs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi-core/src/graph.rs | 845+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi-core/src/lib.rs | 9+++++++++
Anavi/Cargo.toml | 27+++++++++++++++++++++++++++
Anavi/src/app.rs | 955+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi/src/macos_display.rs | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi/src/main.rs | 528+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi/src/painter.rs | 303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anavi/src/theme.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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), + }, +];