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 }