An Obsidian-style interactive graph viewer for org-roam, running as a native desktop window. No Emacs package required — it reads your org-roam.db directly and opens nodes in your existing Emacs process via emacsclient.
Designed for macOS with optional borderless mode and first-class AeroSpace + SketchyBar integration. Also runs on Linux from source.
./naviOn first run the launcher creates a Python venv at ~/.local/share/navi/venv, installs dependencies (pygame, Pillow, numpy, moderngl), and writes ~/.config/navi/config.json with auto-detected paths. Subsequent runs start immediately.
The legacy launcher name org-roam-graph-window still works and forwards to navi.
macOS app (no Python required):
./build/build-macos.sh
open dist/Navi.app- Python 3.8+ (source install only; not needed for
Navi.app) - org-roam v2 — reads
nodes,files,links,tags, andaliasestables - emacsclient + running Emacs server — for open-in-Emacs (
(server-start)ininit.el) - numpy — auto-installed by the launcher; provides ~30x physics speedup over pure Python (falls back gracefully if unavailable)
Config file: ~/.config/navi/config.json (created on first run).
{
"db": "~/.emacs.d/org-roam.db",
"emacsclient": "/opt/homebrew/bin/emacsclient",
"server_name": "server",
"show_fps": true,
"borderless": true
}| Key | Description |
|---|---|
db |
Path to org-roam.db |
emacsclient |
Path to emacsclient binary. Auto-resolved from Homebrew, /usr/local, and Emacs.app if omitted or bare name |
server_name |
Emacs server name (default server) |
show_fps |
Show FPS counter (F toggles at runtime) |
borderless |
Edge-to-edge window with no title bar (see below) |
Legacy config at ~/.config/org-roam-graph/config.json is still read; the next save writes to the Navi path.
Overrides (take precedence over config):
| Method | Example |
|---|---|
| Config file | ~/.config/navi/config.json |
--db |
./navi --db ~/notes/org-roam.db |
--server |
./navi --server work |
--borderless |
Force borderless on this launch |
--check |
Validate config + Emacs connectivity, then exit |
ORG_ROAM_DB |
ORG_ROAM_DB=~/notes/org-roam.db ./navi |
DB auto-detection probes the following paths in order:
| Path | Config |
|---|---|
$ORG_ROAM_DB |
env override (highest priority) |
$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 (new user-dir) |
~/.config/doom/org-roam.db |
Doom 3.x fallback |
~/.doom.d/.local/etc/org-roam.db |
Doom 2.x (legacy user-dir) |
~/.doom.d/org-roam.db |
Doom 2.x fallback |
~/.spacemacs.d/org-roam.db |
Spacemacs |
If your setup uses a custom org-roam-db-location, set db in config.json or pass --db.
Double-click a node (or select it and press Enter / Space) to open it in Emacs. File nodes open the file; headline nodes jump to the heading (goto-char).
GUI apps on macOS get a minimal PATH, so Navi resolves emacsclient to an absolute path at startup (Homebrew, /usr/local, /Applications/Emacs.app/...). It also searches for the Emacs server socket under:
$EMACS_SERVER_SOCKET/$EMACS_SERVER_FILE$TMPDIR/emacs{uid}/(andserver_name/server)/tmp,/private/tmp/var/folders/*/*/T/emacs{uid}/(macOS Terminal vs GUITMPDIRmismatch)
If open fails, an error appears in the status bar for 6 seconds. Ensure Emacs is running with (server-start) in your init.
Recommended config when using the .app:
"emacsclient": "/opt/homebrew/bin/emacsclient""borderless": true removes the title bar and traffic lights for a flush, edge-to-edge graph.
Implementation:
- pygame
NOFRAME+ OpenGL — undecorated SDL window with ModernGL rendering - Cocoa via SDL — resolves the real
NSWindow*throughSDL_GetWindowWMInfo(required becausepygame.OPENGLno longer exposescocoa_windowinget_wm_info()) - Managed window flags —
NSWindowCollectionBehaviorManagedso AeroSpace can tile (retried for ~1s after launch) - Move window — drag the status bar at the bottom of the window (SDL window position, works independently of AeroSpace tiling)
- AeroSpace rules —
layout tilingon Navi window detect +navi-tile.shon focus
If borderless tiling or focus fails after an update, run with debug logging:
# from source
NAVI_DEBUG=1 ./navi
# built bundle (without copying to /Applications)
NAVI_DEBUG=1 ./dist/Navi.app/Contents/MacOS/Navi
# or
NAVI_DEBUG=1 open ./dist/Navi.appLook for NSWindow via SDL on stderr — if you see NSWindow not found, report the full line.
Example ~/.aerospace.toml snippets (also in your dotfiles if you use this stack):
[gaps]
inner.horizontal = 0
inner.vertical = 0
# outer.top may reserve space for SketchyBar
[[on-window-detected]]
if.app-id = 'com.navi.graph'
run = ['layout tiling', 'exec-and-forget sketchybar --trigger aerospace_workspace_change']
on-focus-changed = [
'exec-and-forget ~/.config/aerospace/navi-tile.sh',
'exec-and-forget sketchybar --trigger aerospace_workspace_change',
]Helper: ~/.config/aerospace/navi-tile.sh re-applies layout tiling when Navi is focused.
Workspace pills (1–9) show the front app’s icon on each workspace (first window — no special “Navi wins” override).
| App | Icon |
|---|---|
| Navi | (nerd-font note; same glyph family as Obsidian) |
| Cursor / VS Code | |
| Safari / Arc | |
| … | see ~/.config/sketchybar/plugins/aerospace_all.sh |
Navi is detected by app name Navi, bundle id com.navi.graph, or window title containing navi / org-roam / graph.
Files:
| Path | Role |
|---|---|
~/.config/sketchybar/plugins/aerospace_all.sh |
Single dispatcher; one sketchybar call updates all 9 spaces |
~/.config/sketchybar/plugins/aerospace.sh |
Per-item fallback |
~/.config/sketchybar/sketchybarrc |
Subscribes space_dispatcher to aerospace_workspace_change |
Reload bar: sketchybar --trigger aerospace_workspace_change
To change the Navi icon, edit NAVI_ICON / icon_for in aerospace_all.sh (use glyphs present in MesloLGLDZ Nerd Font).
| Input | Action |
|---|---|
| Drag background | Pan view |
| Swipe + release | Kinetic pan (momentum) |
| Drag node | Move node |
| Swipe node + release | Launch node with momentum |
| Scroll / trackpad | Zoom toward cursor |
| Click node | Select — highlights connections |
| Double-click node | Open in Emacs |
Tab / Shift-Tab |
Cycle through nodes |
Enter / Space |
Open selected node in Emacs |
T |
Cycle colour theme (5 themes) |
G |
Toggle tag colouring |
A |
Toggle age / weathering heatmap |
E |
Toggle particle effects |
D |
Toggle daily notes filter |
O |
Toggle orphan node filter |
L |
Cycle local graph mode (1 → 2 → 3 hops → off) |
/ |
Search nodes by title or alias |
W |
Reload graph from database |
F |
Toggle FPS counter |
P |
Pause / resume physics |
R |
Reset view (pan + zoom) |
H |
Hold to show controls panel |
Q / Escape |
Quit |
| Key | Filter | What it hides |
|---|---|---|
D |
Daily notes | Bare date titles (2024-01-15) or files in daily / dailies / journal / journals |
O |
Orphans | Nodes with no links in or out |
Press L to focus on the selected node's neighbourhood: 1 hop → 2 hops → 3 hops → off. Nodes outside the neighbourhood fade; edges between faded nodes are hidden.
Press / to search by title or alias. Enter jumps to the best match. Escape cancels.
Tags come from org-roam's tags table. Press G to colour nodes by first tag (golden-ratio hue spacing).
#+FILETAGS: :project:
Run M-x org-roam-db-sync in Emacs, then W in Navi to reload.
| Stage | Age | Visual |
|---|---|---|
| Fresh | ≤ 7 days | Warm tint |
| Recent | 8–30 days | Normal |
| Aging | 31–90 days | Grey shift, rust specks |
| Worn | 91–270 days | Greyer, rust + lichen |
| Old | 271–540 days | Heavy grey |
| Ancient | > 540 days | Near-grey, cracks |
Ambient particle clouds per node; comet trails when flinging nodes. Toggle with E.
File-level nodes — solid gradient spheres. Headline-level nodes — inner ring; open jumps to heading position.
Press T to cycle: Obsidian → Forest → Ocean (default) → Ember → Mono.
Monospace: Meslo LG Nerd Font (macOS ~/Library/Fonts/), JetBrains/Meslo on Linux, system fallbacks. CJK titles use Noto Sans JP / PingFang when available.
The shell launcher (navi) and navi.py run on any OS with Python 3.8+ and a display. The .app bundle is macOS arm64 only; everything else uses the script path.
emacsclient is auto-detected from: Homebrew, MacPorts, /usr/local/bin, /usr/bin, Snap, ~/.local/bin, and ~/.nix-profile/bin.
Emacs server socket is probed from $EMACS_SERVER_SOCKET, $XDG_RUNTIME_DIR/emacs/ (standard on systemd Linux), $TMPDIR/emacs{uid}/, and the macOS /var/folders tree.
git clone <repo> ~/navi
cd ~/navi
./navi --check # validates paths, emacsclient, and server before first launch
./navi# run once to ensure deps
nix-shell -p python3 python3Packages.pygame python3Packages.pillow
./naviOr point nix-env/home-manager at the repo — emacsclient from ~/.nix-profile/bin is auto-detected.
{
"db": "~/notes/org-roam.db",
"emacsclient": "~/.local/bin/emacsclient",
"server_name": "server"
}./build/build-macos.sh| Output | dist/Navi.app (~40 MB) |
| Bundle ID | com.navi.graph |
| Minimum OS | macOS 11+ arm64 |
| Config | ~/.config/navi/config.json |
| Gatekeeper | Unsigned — xattr -cr Navi.app if blocked |
open dist/Navi.app
cp -R dist/Navi.app /Applications/After code changes, rebuild and quit/reopen the app.
python3 tools/make-icon.py assets/logo-source.pngWrites assets/icon.png and assets/icon.icns for the macOS bundle.
Installed automatically into an isolated venv (source install) or bundled in Navi.app.
| Package | License | Purpose |
|---|---|---|
| pygame | LGPL 2.1 | Window, input, OpenGL context |
| moderngl | MIT | GPU graph rendering |
| numpy | BSD | Vectorised physics |
| Pillow | HPND | Label/status rasterisation |
To stress-test Navi without a real org-roam setup:
python3 tools/make-test-db.py --tiers # generates 300, 800, and 1500-node DBs
./navi --db /tmp/navi-test-1500.db # launch against the large one| Flag | Description |
|---|---|
-n N |
Number of nodes (default 500) |
-o PATH |
Output path (default /tmp/navi-test.db) |
--tiers |
Generate all three tier DBs and print launch commands |
--seed N |
Random seed for reproducibility |
The generated graph has a power-law degree distribution (realistic hubs), 8 topic tags, ~10% headline nodes, and a mix of fresh/old modification times to exercise the age heatmap.
Rendering uses ModernGL (OpenGL 3.3 core / Metal backend on macOS) with a single instanced draw call per layer (nodes, glow, particles, edges). Measured on Apple M-series:
| Nodes | Navi CPU | WindowServer |
|---|---|---|
| ~10 (typical small graph) | ~26 % | ~24 % |
| physics settling | ~45–55 % | ~30 % |
Physics uses numpy vectorised broadcasting (auto-installed). Physics ms/frame:
| Nodes | numpy | pure Python |
|---|---|---|
| 300 | 0.2 ms | ~6 ms |
| 800 | 1.4 ms | — |
| 1500 | 5 ms | — |
Without numpy, the soft limit is ~250 nodes at 60 fps. The GPU renderer scales to large graphs without additional CPU cost — rendering 1 500 nodes costs the same as rendering 10.
navi Shell launcher (self-bootstrapping venv)
navi.py Main application
org-roam-graph-window Legacy launcher (forwards to navi)
build/build-macos.sh PyInstaller → dist/Navi.app
build/package-dmg.sh Package dist/Navi.app → distributable DMG
build/navi.spec macOS bundle spec
tools/make-test-db.py Generate synthetic org-roam DB for testing
assets/icon.icns macOS app icon
LICENSES/ Dependency license texts
~/.config/navi/config.json Per-user config (created on first run)
CHANGELOG.md Version history
| Navi | org-roam-ui | |
|---|---|---|
| Emacs package needed | No | Yes |
| Emacs server needed | Only for open-in-Emacs | Yes (always) |
| Runs in | Native OS window | Browser |
| Setup | Script or .app |
Package install + config |
| Daily notes filter | Yes | No |
| Local graph (N-hop) | Yes | Yes |
| Search by title/alias | Yes | Partial |
| Tag-based colouring | Yes | Yes |
| Age heatmap | Yes | No |
| Headline node jump | Yes | Yes |
| Live DB reload | Manual (W) |
Yes |
| Particle effects | Yes | No |
| 3D graph | No | Yes |
| AeroSpace / borderless | Yes (macOS) | N/A |
Navi application code: see repository license. Bundled dependencies: pygame (LGPL 2.1), Pillow (HPND). Ship LICENSES/ with binary releases.

