navi

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

main.rs (20706B)


      1 mod app;
      2 mod painter;
      3 mod theme;
      4 
      5 #[cfg(target_os = "macos")]
      6 mod macos_display;
      7 
      8 use std::num::NonZeroU32;
      9 use std::sync::Arc;
     10 use std::time::{Duration, Instant};
     11 
     12 use egui_winit::winit;
     13 use glutin::context::NotCurrentGlContext;
     14 use glutin::display::{GetGlDisplay, GlDisplay};
     15 use glutin::prelude::GlSurface;
     16 use raw_window_handle::HasWindowHandle;
     17 use winit::application::ApplicationHandler;
     18 use winit::event::WindowEvent;
     19 use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
     20 use winit::window::Window;
     21 
     22 use navi_core::{
     23     config::{detect_db, expand_tilde, Config},
     24     load_graph, Graph,
     25 };
     26 
     27 /// Cross-thread events delivered to winit's main event loop.
     28 ///
     29 /// `Vsync` is the heart of the new pacer: a dedicated background thread runs a
     30 /// `CADisplayLink` against its own NSRunLoop, and on each tick (4.17 ms on this
     31 /// 240 Hz panel) it sends one of these to the main thread. We render exactly
     32 /// once per vsync — no mouse-driven extra repaints, no main-thread sleeps.
     33 #[derive(Debug)]
     34 pub enum UserEvent {
     35     Vsync,
     36 }
     37 
     38 fn main() -> Result<(), Box<dyn std::error::Error>> {
     39     let mut cfg = Config::load();
     40     if cfg.db.is_empty() {
     41         cfg.db = detect_db();
     42     }
     43     if cfg.emacsclient.is_empty() {
     44         cfg.emacsclient = navi_core::EmacsClient::new("", &cfg.server_name)
     45             .binary
     46             .clone();
     47     }
     48     let db_path = expand_tilde(&cfg.db);
     49 
     50     let (raw_nodes, raw_edges) = load_graph(&db_path).map_err(|e| {
     51         eprintln!("navi: failed to load {db_path}: {e}");
     52         e
     53     })?;
     54     let n_nodes = raw_nodes.len();
     55     let n_edges = raw_edges.len();
     56     let graph = Graph::new(raw_nodes, raw_edges);
     57     eprintln!("navi: loaded {n_nodes} nodes, {n_edges} edges");
     58     cfg.save();
     59 
     60     let event_loop = EventLoop::<UserEvent>::with_user_event().build()?;
     61     event_loop.set_control_flow(ControlFlow::Wait);
     62     let proxy = event_loop.create_proxy();
     63 
     64     let mut app_state = AppState::new(graph, cfg, db_path, proxy);
     65     event_loop.run_app(&mut app_state)?;
     66     Ok(())
     67 }
     68 
     69 /// All runtime state owned by the winit `ApplicationHandler`.
     70 struct AppState {
     71     proxy: EventLoopProxy<UserEvent>,
     72     graph: Option<Graph>,
     73     cfg: Option<Config>,
     74     db_path: Option<String>,
     75 
     76     // Created on `resumed` once we have an `ActiveEventLoop`.
     77     gl_window: Option<GlutinWindow>,
     78     gl: Option<Arc<glow::Context>>,
     79     egui_glow: Option<egui_glow::EguiGlow>,
     80     app: Option<app::NaviApp>,
     81 
     82     // Idle scheduling: when we're not active, we don't run the display link;
     83     // we wake on a timer instead. `next_idle_wake` tells `new_events` that the
     84     // resume-time fired and a redraw is due.
     85     next_idle_wake: Option<Instant>,
     86 
     87     // Stats
     88     frame_times: std::collections::VecDeque<f32>,
     89     last_frame: Instant,
     90     last_fps_log: Option<Instant>,
     91 
     92     // Wall-clock of the last actual paint. Used to coalesce back-to-back
     93     // redraw triggers (e.g. a Vsync user-event arriving microseconds after a
     94     // RedrawRequested from a focus/input event). At 240 Hz the vsync window is
     95     // ~4.17 ms; anything closer than half that is duplicate work that just
     96     // contends with the compositor for the same presentation slot.
     97     last_paint_at: Option<Instant>,
     98 }
     99 
    100 impl AppState {
    101     fn new(graph: Graph, cfg: Config, db_path: String, proxy: EventLoopProxy<UserEvent>) -> Self {
    102         Self {
    103             proxy,
    104             graph: Some(graph),
    105             cfg: Some(cfg),
    106             db_path: Some(db_path),
    107             gl_window: None,
    108             gl: None,
    109             egui_glow: None,
    110             app: None,
    111             next_idle_wake: None,
    112             frame_times: std::collections::VecDeque::new(),
    113             last_frame: Instant::now(),
    114             last_fps_log: None,
    115             last_paint_at: None,
    116         }
    117     }
    118 }
    119 
    120 impl ApplicationHandler<UserEvent> for AppState {
    121     fn resumed(&mut self, event_loop: &ActiveEventLoop) {
    122         if self.gl_window.is_some() {
    123             return;
    124         }
    125 
    126         let (gl_window, gl) = unsafe { GlutinWindow::create(event_loop) };
    127         let gl = Arc::new(gl);
    128         gl_window.window().set_visible(true);
    129 
    130         let egui_glow = egui_glow::EguiGlow::new(event_loop, gl.clone(), None, None, true);
    131         setup_fonts(&egui_glow.egui_ctx);
    132 
    133         // Spawn the display link on a dedicated bg thread. From there it sends
    134         // `UserEvent::Vsync` to the main thread on every tick. The link is
    135         // started in the *paused* state — we'll resume it whenever the app
    136         // enters its full-speed mode.
    137         #[cfg(target_os = "macos")]
    138         macos_display::install_bg_link(gl_window.window(), self.proxy.clone());
    139 
    140         let graph = self.graph.take().expect("graph initialised in main()");
    141         let cfg = self.cfg.take().expect("cfg initialised in main()");
    142         let db_path = self.db_path.take().expect("db_path initialised in main()");
    143         let app = app::NaviApp::new(graph, cfg, db_path);
    144 
    145         self.gl_window = Some(gl_window);
    146         self.gl = Some(gl);
    147         self.egui_glow = Some(egui_glow);
    148         self.app = Some(app);
    149     }
    150 
    151     fn window_event(
    152         &mut self,
    153         event_loop: &ActiveEventLoop,
    154         _window_id: winit::window::WindowId,
    155         event: WindowEvent,
    156     ) {
    157         if matches!(event, WindowEvent::CloseRequested | WindowEvent::Destroyed) {
    158             event_loop.exit();
    159             return;
    160         }
    161         if let WindowEvent::Resized(size) = &event {
    162             if let Some(gw) = self.gl_window.as_ref() {
    163                 gw.resize(*size);
    164             }
    165         }
    166 
    167         if matches!(event, WindowEvent::RedrawRequested) {
    168             self.redraw(event_loop);
    169             return;
    170         }
    171 
    172         // True iff we're currently in idle (link paused, waiting on the 33 ms
    173         // resume timer). In that mode we *do* want input/focus events to kick
    174         // an immediate redraw so the cadence transition happens within one
    175         // frame. In active mode we explicitly do NOT request_redraw — the
    176         // CADisplayLink Vsync user-events are the sole paint trigger, and any
    177         // extra request_redraw causes a double-paint that contends with the
    178         // compositor and produces a missed-vsync outlier.
    179         let is_idle = self.next_idle_wake.is_some();
    180 
    181         // Focus changes drive the idle/active cadence. Gaining focus instantly
    182         // restores the full-speed tier; losing focus lets the next paint drop
    183         // us straight into idle mode.
    184         if let WindowEvent::Focused(focused) = &event {
    185             if let Some(app) = self.app.as_mut() {
    186                 app.set_focused(*focused);
    187             }
    188             if *focused && is_idle {
    189                 if let Some(gw) = self.gl_window.as_ref() {
    190                     gw.window().request_redraw();
    191                 }
    192             }
    193         }
    194 
    195         // Any user input bumps the activity timer (keeps us in full-speed mode
    196         // for `idle_grace`). Only kick a redraw if we're idle right now.
    197         let is_input = matches!(
    198             event,
    199             WindowEvent::CursorMoved { .. }
    200                 | WindowEvent::CursorEntered { .. }
    201                 | WindowEvent::MouseInput { .. }
    202                 | WindowEvent::MouseWheel { .. }
    203                 | WindowEvent::KeyboardInput { .. }
    204                 | WindowEvent::ModifiersChanged(..)
    205                 | WindowEvent::Ime(..)
    206                 | WindowEvent::Touch(..)
    207                 | WindowEvent::PinchGesture { .. }
    208                 | WindowEvent::PanGesture { .. }
    209                 | WindowEvent::RotationGesture { .. }
    210         );
    211         if is_input {
    212             if let Some(app) = self.app.as_mut() {
    213                 app.touch_input();
    214             }
    215             if is_idle {
    216                 if let Some(gw) = self.gl_window.as_ref() {
    217                     gw.window().request_redraw();
    218                 }
    219             }
    220         }
    221 
    222         // Feed the event to egui_winit so it can update its input state. We
    223         // *deliberately* ignore `event_response.repaint` — the whole point of
    224         // this rewrite is to decouple our redraw cadence from input arrival.
    225         // Display vsync ticks are the only signal that triggers a paint.
    226         if let (Some(eg), Some(gw)) = (self.egui_glow.as_mut(), self.gl_window.as_ref()) {
    227             let _ = eg.on_window_event(gw.window(), &event);
    228         }
    229     }
    230 
    231     fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
    232         match event {
    233             UserEvent::Vsync => {
    234                 // Paint *directly* — skip the request_redraw → RedrawRequested
    235                 // round-trip through winit's queue. That extra hop costs us
    236                 // some latency on every frame and, when we're close to the
    237                 // vsync deadline, occasionally pushes us into the next vsync
    238                 // window and produces a 8–12 ms outlier (visible micro-stutter).
    239                 self.redraw(event_loop);
    240             }
    241         }
    242     }
    243 
    244     fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) {
    245         if let winit::event::StartCause::ResumeTimeReached { .. } = cause {
    246             if let Some(gw) = self.gl_window.as_ref() {
    247                 gw.window().request_redraw();
    248             }
    249             self.next_idle_wake = None;
    250         }
    251     }
    252 
    253     fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
    254         if let Some(eg) = self.egui_glow.as_mut() {
    255             eg.destroy();
    256         }
    257     }
    258 }
    259 
    260 impl AppState {
    261     fn redraw(&mut self, event_loop: &ActiveEventLoop) {
    262         let now = Instant::now();
    263 
    264         // Coalesce duplicate redraw triggers. At 240 Hz the vsync window is
    265         // ~4.17 ms; a paint that lands within half of that of the previous
    266         // paint is a duplicate (e.g. a Vsync arrived microseconds after a
    267         // RedrawRequested from the same vsync, or two redraw paths fired in
    268         // rapid succession). Painting twice in the same compositor window
    269         // contends for the same presentation slot and shows up as an 8–12 ms
    270         // worst-case frame in the FPS log even when avg is locked at 4.17.
    271         if let Some(prev) = self.last_paint_at {
    272             if now.duration_since(prev) < Duration::from_micros(2_000) {
    273                 return;
    274             }
    275         }
    276 
    277         // Frame timing
    278         let dt = now.duration_since(self.last_frame).as_secs_f32().min(0.05);
    279         self.last_frame = now;
    280         self.frame_times.push_back(dt);
    281         while self.frame_times.iter().sum::<f32>() > 1.0 && self.frame_times.len() > 2 {
    282             self.frame_times.pop_front();
    283         }
    284 
    285         let needs_full_speed = match self.app.as_ref() {
    286             Some(a) => a.needs_full_speed(),
    287             None => false,
    288         };
    289 
    290         // Toggle the display link based on app state. Active = link runs at
    291         // 240 Hz and pumps Vsync events; Idle = link paused, OS drops the
    292         // refresh tier, we use a 33 ms timer for the next wake-up.
    293         #[cfg(target_os = "macos")]
    294         macos_display::set_active(needs_full_speed);
    295         if !needs_full_speed {
    296             let next = now + Duration::from_millis(33);
    297             self.next_idle_wake = Some(next);
    298             event_loop.set_control_flow(ControlFlow::WaitUntil(next));
    299         } else {
    300             // While active, control flow stays Wait — we paint only when a
    301             // Vsync user-event arrives. No polling, no busy-looping. Clear
    302             // `next_idle_wake` so the input/focus handlers in window_event
    303             // know we're no longer in the idle path and don't fire extra
    304             // request_redraw calls (which would cause double-paints).
    305             self.next_idle_wake = None;
    306             event_loop.set_control_flow(ControlFlow::Wait);
    307         }
    308 
    309         // Run the egui frame.
    310         let gw = self.gl_window.as_mut().expect("gl_window");
    311         let eg = self.egui_glow.as_mut().expect("egui_glow");
    312         let app = self.app.as_mut().expect("app");
    313 
    314         eg.run(gw.window(), |ctx| {
    315             app.update(ctx);
    316         });
    317 
    318         // Paint.
    319         let theme_bg = app.bg_color();
    320         unsafe {
    321             use glow::HasContext as _;
    322             let gl = self.gl.as_ref().expect("gl");
    323             gl.clear_color(theme_bg[0], theme_bg[1], theme_bg[2], 1.0);
    324             gl.clear(glow::COLOR_BUFFER_BIT);
    325         }
    326         eg.paint(gw.window());
    327         let _ = gw.swap_buffers();
    328         self.last_paint_at = Some(now);
    329 
    330         // Diagnostics
    331         if std::env::var_os("NAVI_FPS_LOG").is_some() {
    332             let due = self
    333                 .last_fps_log
    334                 .map_or(true, |t| t.elapsed() >= Duration::from_secs(1));
    335             if due && self.frame_times.len() >= 2 {
    336                 let n = self.frame_times.len() as f32;
    337                 let total: f32 = self.frame_times.iter().sum();
    338                 let avg_ms = total / n * 1000.0;
    339                 let max_ms = self
    340                     .frame_times
    341                     .iter()
    342                     .copied()
    343                     .fold(0.0_f32, f32::max)
    344                     * 1000.0;
    345                 let fps = n / total;
    346                 #[cfg(target_os = "macos")]
    347                 let link_info = {
    348                     let ticks = macos_display::tick_count();
    349                     let interval_us = macos_display::vsync_interval()
    350                         .map(|d| d.as_micros() as u64)
    351                         .unwrap_or(0);
    352                     let hz = if interval_us > 0 {
    353                         1_000_000.0 / interval_us as f64
    354                     } else {
    355                         0.0
    356                     };
    357                     format!("  link_total_ticks={ticks}  link_interval={interval_us}us ({hz:.0}Hz)")
    358                 };
    359                 #[cfg(not(target_os = "macos"))]
    360                 let link_info = String::new();
    361                 eprintln!(
    362                     "navi: fps={:.1}  avg={:.2}ms  worst={:.2}ms  frames={}{}",
    363                     fps,
    364                     avg_ms,
    365                     max_ms,
    366                     self.frame_times.len(),
    367                     link_info,
    368                 );
    369                 self.last_fps_log = Some(now);
    370             }
    371         }
    372     }
    373 }
    374 
    375 // ─── Glutin context plumbing ───────────────────────────────────────────────────
    376 
    377 struct GlutinWindow {
    378     window: Window,
    379     gl_context: glutin::context::PossiblyCurrentContext,
    380     gl_display: glutin::display::Display,
    381     gl_surface: glutin::surface::Surface<glutin::surface::WindowSurface>,
    382 }
    383 
    384 impl GlutinWindow {
    385     /// Construct the window + GL context. Adapted from egui_glow's pure_glow example.
    386     /// Vsync is left ON (`SwapInterval::Wait(1)`) — it's harmless at 0.5 ms render
    387     /// time and gives the OS a clean signal that we want display sync.
    388     unsafe fn create(event_loop: &ActiveEventLoop) -> (Self, glow::Context) {
    389         let window_attrs = winit::window::WindowAttributes::default()
    390             .with_resizable(true)
    391             .with_inner_size(winit::dpi::LogicalSize::new(1400.0, 900.0))
    392             .with_min_inner_size(winit::dpi::LogicalSize::new(400.0, 300.0))
    393             .with_title("Navi")
    394             .with_visible(false);
    395 
    396         let cfg_template = glutin::config::ConfigTemplateBuilder::new()
    397             .prefer_hardware_accelerated(None)
    398             .with_depth_size(0)
    399             .with_stencil_size(0)
    400             .with_transparency(false);
    401 
    402         let (mut window_opt, gl_config) = glutin_winit::DisplayBuilder::new()
    403             .with_preference(glutin_winit::ApiPreference::FallbackEgl)
    404             .with_window_attributes(Some(window_attrs.clone()))
    405             .build(event_loop, cfg_template, |mut it| {
    406                 it.next().expect("no matching gl config")
    407             })
    408             .expect("failed to create gl_config");
    409 
    410         let gl_display = gl_config.display();
    411         let raw_window_handle = window_opt
    412             .as_ref()
    413             .map(|w| w.window_handle().expect("window handle").as_raw());
    414 
    415         let context_attrs =
    416             glutin::context::ContextAttributesBuilder::new().build(raw_window_handle);
    417         let fallback_attrs = glutin::context::ContextAttributesBuilder::new()
    418             .with_context_api(glutin::context::ContextApi::Gles(None))
    419             .build(raw_window_handle);
    420 
    421         let not_current = unsafe {
    422             gl_display
    423                 .create_context(&gl_config, &context_attrs)
    424                 .unwrap_or_else(|_| {
    425                     gl_display
    426                         .create_context(&gl_config, &fallback_attrs)
    427                         .expect("failed to create gl context")
    428                 })
    429         };
    430 
    431         let window = window_opt.take().unwrap_or_else(|| {
    432             glutin_winit::finalize_window(event_loop, window_attrs.clone(), &gl_config)
    433                 .expect("failed to finalize window")
    434         });
    435 
    436         let (w, h): (u32, u32) = window.inner_size().into();
    437         let surface_attrs =
    438             glutin::surface::SurfaceAttributesBuilder::<glutin::surface::WindowSurface>::new()
    439                 .build(
    440                     window.window_handle().expect("window handle").as_raw(),
    441                     NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN),
    442                     NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN),
    443                 );
    444 
    445         let gl_surface = unsafe {
    446             gl_display
    447                 .create_window_surface(&gl_config, &surface_attrs)
    448                 .expect("failed to create surface")
    449         };
    450         let gl_context = not_current
    451             .make_current(&gl_surface)
    452             .expect("failed to make context current");
    453 
    454         // Vsync OFF. macOS OpenGL's swap-vsync is capped at ~108 Hz on ProMotion
    455         // panels regardless of what tier the display is actually running at, so
    456         // letting it block here would clamp us to 108 fps even though the display
    457         // link is firing at 240 Hz. Pacing is owned entirely by the bg-thread
    458         // display link in macos_display.rs.
    459         let _ = gl_surface.set_swap_interval(&gl_context, glutin::surface::SwapInterval::DontWait);
    460 
    461         let gl = unsafe {
    462             glow::Context::from_loader_function(|s| {
    463                 let cstr = std::ffi::CString::new(s).unwrap();
    464                 gl_display.get_proc_address(&cstr)
    465             })
    466         };
    467 
    468         (
    469             Self {
    470                 window,
    471                 gl_context,
    472                 gl_display,
    473                 gl_surface,
    474             },
    475             gl,
    476         )
    477     }
    478 
    479     fn window(&self) -> &Window {
    480         &self.window
    481     }
    482 
    483     fn resize(&self, size: winit::dpi::PhysicalSize<u32>) {
    484         let _ = &self.gl_display; // silence "unused"
    485         self.gl_surface.resize(
    486             &self.gl_context,
    487             NonZeroU32::new(size.width).unwrap_or(NonZeroU32::MIN),
    488             NonZeroU32::new(size.height).unwrap_or(NonZeroU32::MIN),
    489         );
    490     }
    491 
    492     fn swap_buffers(&self) -> glutin::error::Result<()> {
    493         self.gl_surface.swap_buffers(&self.gl_context)
    494     }
    495 }
    496 
    497 // ─── Fonts ────────────────────────────────────────────────────────────────────
    498 
    499 /// Load a prioritised font fallback chain so egui can render:
    500 ///   • Nerd Font symbols (powerline, devicons, file icons)  — Meslo NF
    501 ///   • Japanese / CJK                                       — Noto Sans JP
    502 ///   • Broad Unicode catch-all (~50 k glyphs)               — Arial Unicode
    503 ///   • Extra maths / misc symbols                           — Noto Sans Symbols 2
    504 fn setup_fonts(ctx: &egui::Context) {
    505     let home = std::env::var("HOME").unwrap_or_default();
    506     let candidates: &[(&str, String)] = &[
    507         ("nerd",     format!("{home}/Library/Fonts/MesloLGLNerdFont-Regular.ttf")),
    508         ("jp",       "/Library/Fonts/NotoSansJP-Regular.otf".into()),
    509         ("unicode",  "/Library/Fonts/Arial Unicode.ttf".into()),
    510         ("symbols2", "/Library/Fonts/NotoSansSymbols2-Regular.ttf".into()),
    511     ];
    512     let mut fonts = egui::FontDefinitions::default();
    513     for (name, path) in candidates {
    514         if let Ok(data) = std::fs::read(path) {
    515             fonts
    516                 .font_data
    517                 .insert((*name).to_string(), egui::FontData::from_owned(data));
    518             for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
    519                 fonts
    520                     .families
    521                     .entry(family)
    522                     .or_default()
    523                     .push((*name).to_string());
    524             }
    525         }
    526     }
    527     ctx.set_fonts(fonts);
    528 }