| Bring vidya in cfd3e36 nandi 19d ago | 1 | //! Window, GL context, and the caller-driven frame loop. |
| 2 | //! |
| 3 | //! The C ABI is pull-style: the caller owns the loop and calls |
| 4 | //! [`App::begin_frame`] / [`App::end_frame`] around its own widget calls. |
| 5 | //! `eframe` inverts that — it owns the loop and calls the app — so this backend |
| 6 | //! drives `winit` with `pump_app_events` and paints through `egui_glow`, |
| 7 | //! keeping the ABI (and every consumer of it) unchanged. |
| 8 | //! |
| 9 | //! One thing does **not** get to move outside the event loop: the present. |
| 10 | //! Wayland gives a surface one buffer per frame callback, and winit tracks |
| 11 | //! those callbacks itself. Presenting from outside its `RedrawRequested` |
| 12 | //! dispatch desynchronizes that bookkeeping — the compositor stops calling |
| 13 | //! back, the next `swap_buffers` blocks inside EGL with the session waiting on |
| 14 | //! it, and the whole desktop stalls for seconds. So [`App::end_frame`] hands |
| 15 | //! the finished frame to [`Handler`] and pumps until it is painted from inside |
| 16 | //! the callback, which is also what paces the caller's loop. |
| 17 | |
| 18 | use std::sync::Arc; |
| 19 | use std::time::{Duration, Instant}; |
| 20 | |
| 21 | use egui::ViewportId; |
| 22 | use glutin::config::{Config, ConfigTemplateBuilder}; |
| 23 | use glutin::context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}; |
| 24 | use glutin::display::GetGlDisplay as _; |
| 25 | use glutin::prelude::*; |
| 26 | use glutin::surface::{Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface}; |
| 27 | use glutin_winit::{DisplayBuilder, GlWindow as _}; |
| 28 | use vidya_core::Theme; |
| 29 | use winit::application::ApplicationHandler; |
| 30 | use winit::dpi::LogicalSize; |
| 31 | use winit::event::WindowEvent; |
| 32 | use winit::event_loop::{ActiveEventLoop, EventLoop}; |
| 33 | use winit::platform::pump_events::EventLoopExtPumpEvents as _; |
| 34 | use winit::raw_window_handle::HasWindowHandle as _; |
| 35 | use winit::window::{Window, WindowId}; |
| 36 | |
| 37 | use crate::ui::Stack; |
| 38 | |
| 39 | /// How long `vidya_open` waits for the platform to hand us a window. |
| 40 | const OPEN_TIMEOUT: Duration = Duration::from_secs(5); |
| 41 | |
| 42 | /// How long a finished frame waits for a frame callback before being dropped. |
| 43 | /// A window nobody is compositing (minimized, on another workspace) simply |
| 44 | /// stops presenting; the caller's loop keeps running. |
| 45 | const PRESENT_TIMEOUT: Duration = Duration::from_millis(250); |
| 46 | |
| 47 | /// Pacing floor, so a caller that never sets a target FPS still yields. |
| 48 | const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666); |
| 49 | |
| 50 | /// Build the event loop, preferring X11 where both backends exist. |
| 51 | /// |
| 52 | /// Native Wayland does not survive this ABI's shape: the caller owns the loop, |
| 53 | /// so winit is driven with `pump_app_events`, and a surface driven that way |
| 54 | /// stops receiving frame callbacks after its first commit — the window never |
| 55 | /// gets a second frame and the session stalls behind it. Under X11 (XWayland |
| 56 | /// included) presentation does not depend on those callbacks, and the same loop |
| 57 | /// runs at full frame rate. Falls back to the default backend when X11 is |
| 58 | /// unavailable, so a compositor without XWayland still gets a window. |
| 59 | #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] |
| 60 | fn build_event_loop() -> Result<EventLoop<()>, String> { |
| 61 | use winit::platform::x11::EventLoopBuilderExtX11 as _; |
| 62 | |
| 63 | if let Ok(el) = EventLoop::builder().with_x11().build() { |
| 64 | return Ok(el); |
| 65 | } |
| 66 | eprintln!("vidya: X11 unavailable; falling back to Wayland (expect stalls)"); |
| 67 | EventLoop::builder() |
| 68 | .build() |
| 69 | .map_err(|e| format!("event loop: {e}")) |
| 70 | } |
| 71 | |
| 72 | /// Android has no display connection to choose: the activity already owns one, |
| 73 | /// and winit reaches it through the handle the glue was started with. Without |
| 74 | /// that handle there is no event loop to build at all, which is why |
| 75 | /// `libvidya.so` is the NativeActivity's own library — see `android.rs`. |
| 76 | #[cfg(target_os = "android")] |
| 77 | fn build_event_loop() -> Result<EventLoop<()>, String> { |
| 78 | use winit::platform::android::EventLoopBuilderExtAndroid as _; |
| 79 | |
| 80 | let app = crate::android::android_app() |
| 81 | .ok_or_else(|| "no AndroidApp: vidya_open ran outside android_main".to_owned())?; |
| 82 | EventLoop::builder() |
| 83 | .with_android_app(app) |
| 84 | .build() |
| 85 | .map_err(|e| format!("event loop: {e}")) |
| 86 | } |
| 87 | |
| 88 | #[cfg(not(all(unix, not(target_os = "macos"))))] |
| 89 | fn build_event_loop() -> Result<EventLoop<()>, String> { |
| 90 | EventLoop::builder() |
| 91 | .build() |
| 92 | .map_err(|e| format!("event loop: {e}")) |
| 93 | } |
| 94 | |
| 95 | /// Window, GL surface, painter, and egui input translation. |
| 96 | /// |
| 97 | /// Created on the first `resumed`, the only point where winit guarantees a |
| 98 | /// usable display connection on every platform. |
| 99 | /// |
| 100 | /// The surface is the one part that does not live as long as the rest. Android |
| 101 | /// takes the native window away whenever the activity leaves the foreground and |
| 102 | /// hands back a *new* one on the way in, so a surface built on the old one |
| 103 | /// presents to nothing — a black window, with `swap_buffers` reporting nothing |
| 104 | /// wrong. It is dropped on `suspended` and rebuilt on the next `resumed`; the |
| 105 | /// context and its textures outlive both, which is why the app comes back with |
| 106 | /// its fonts and images intact rather than reloading them. |
| 107 | struct Gl { |
| 108 | window: Window, |
| 109 | /// `None` between `suspended` and the `resumed` that follows it. |
| 110 | surface: Option<Surface<WindowSurface>>, |
| 111 | /// Kept for that rebuild: a surface has to match the config its context |
| 112 | /// was created against. |
| 113 | config: Config, |
| 114 | context: PossiblyCurrentContext, |
| 115 | painter: egui_glow::Painter, |
| 116 | winit_state: egui_winit::State, |
| 117 | } |
| 118 | |
| 119 | impl Gl { |
| 120 | fn create( |
| 121 | el: &ActiveEventLoop, |
| 122 | egui_ctx: &egui::Context, |
| 123 | title: &str, |
| 124 | width: u32, |
| 125 | height: u32, |
| 126 | ) -> Result<Self, String> { |
| 127 | let attrs = Window::default_attributes() |
| 128 | .with_title(title) |
| 129 | .with_inner_size(LogicalSize::new(width as f64, height as f64)); |
| 130 | |
| 131 | let (window, config) = DisplayBuilder::new() |
| 132 | .with_window_attributes(Some(attrs)) |
| 133 | .build( |
| 134 | el, |
| 135 | ConfigTemplateBuilder::new().with_alpha_size(0), |
| 136 | // egui does its own anti-aliasing; this just avoids picking a |
| 137 | // degenerate config. |
| 138 | |configs| { |
| 139 | configs |
| 140 | .reduce(|best, c| { |
| 141 | if c.num_samples() > best.num_samples() { |
| 142 | c |
| 143 | } else { |
| 144 | best |
| 145 | } |
| 146 | }) |
| 147 | .expect("no GL config") |
| 148 | }, |
| 149 | ) |
| 150 | .map_err(|e| format!("GL display: {e}"))?; |
| 151 | let window = window.ok_or_else(|| "no window was created".to_owned())?; |
| 152 | |
| 153 | let raw = window |
| 154 | .window_handle() |
| 155 | .map_err(|e| format!("window handle: {e}"))? |
| 156 | .as_raw(); |
| 157 | let display = config.display(); |
| 158 | |
| 159 | // Desktop GL first, GLES second — the order eframe uses. |
| 160 | let context = unsafe { |
| 161 | display |
| 162 | .create_context(&config, &ContextAttributesBuilder::new().build(Some(raw))) |
| 163 | .or_else(|_| { |
| 164 | display.create_context( |
| 165 | &config, |
| 166 | &ContextAttributesBuilder::new() |
| 167 | .with_context_api(ContextApi::Gles(None)) |
| 168 | .build(Some(raw)), |
| 169 | ) |
| 170 | }) |
| 171 | .map_err(|e| format!("GL context: {e}"))? |
| 172 | }; |
| 173 | |
| 174 | let surface = build_surface(&window, &config)?; |
| 175 | let context = context |
| 176 | .make_current(&surface) |
| 177 | .map_err(|e| format!("make current: {e}"))?; |
| 178 | set_vsync(&surface, &context); |
| 179 | |
| 180 | let glow_ctx = unsafe { |
| 181 | glow::Context::from_loader_function_cstr(|s| display.get_proc_address(s).cast()) |
| 182 | }; |
| 183 | let painter = egui_glow::Painter::new(Arc::new(glow_ctx), "", None, false) |
| 184 | .map_err(|e| format!("painter: {e}"))?; |
| 185 | |
| 186 | let winit_state = egui_winit::State::new( |
| 187 | egui_ctx.clone(), |
| 188 | ViewportId::ROOT, |
| 189 | &window, |
| 190 | None, |
| 191 | window.theme(), |
| 192 | Some(painter.max_texture_side()), |
| 193 | ); |
| 194 | |
| 195 | Ok(Self { |
| 196 | window, |
| 197 | surface: Some(surface), |
| 198 | config, |
| 199 | context, |
| 200 | painter, |
| 201 | winit_state, |
| 202 | }) |
| 203 | } |
| 204 | |
| 205 | /// Give up the surface the platform is about to invalidate. The context is |
| 206 | /// released from this thread first: destroying a surface that is still |
| 207 | /// current is not something EGL owes an answer for. |
| 208 | fn suspend(&mut self) { |
| 209 | if self.surface.is_none() { |
| 210 | return; |
| 211 | } |
| 212 | if let Err(e) = self.context.make_not_current_in_place() { |
| 213 | eprintln!("vidya: releasing the GL context failed: {e}"); |
| 214 | } |
| 215 | self.surface = None; |
| 216 | } |
| 217 | |
| 218 | /// Build a surface on whatever native window the platform has now, and make |
| 219 | /// the context current on it again. A failure here leaves the surface |
| 220 | /// `None`, so the loop keeps running and drops frames rather than painting |
| 221 | /// into a surface that is not there. |
| 222 | fn resume(&mut self) { |
| 223 | if self.surface.is_some() { |
| 224 | return; |
| 225 | } |
| 226 | let surface = match build_surface(&self.window, &self.config) { |
| 227 | Ok(surface) => surface, |
| 228 | Err(e) => { |
| 229 | eprintln!("vidya: rebuilding the GL surface failed: {e}"); |
| 230 | return; |
| 231 | } |
| 232 | }; |
| 233 | if let Err(e) = self.context.make_current(&surface) { |
| 234 | eprintln!("vidya: make current failed on resume: {e}"); |
| 235 | return; |
| 236 | } |
| 237 | set_vsync(&surface, &self.context); |
| 238 | // The new window is rarely the old one's size — a keyboard may have |
| 239 | // gone away under it, or the device turned. |
| 240 | self.window.request_redraw(); |
| 241 | self.surface = Some(surface); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | fn build_surface(window: &Window, config: &Config) -> Result<Surface<WindowSurface>, String> { |
| 246 | let attrs = window |
| 247 | .build_surface_attributes(SurfaceAttributesBuilder::new()) |
| 248 | .map_err(|e| format!("surface attributes: {e}"))?; |
| 249 | // SAFETY: the attributes name this window, which outlives the surface. |
| 250 | unsafe { |
| 251 | config |
| 252 | .display() |
| 253 | .create_window_surface(config, &attrs) |
| 254 | .map_err(|e| format!("GL surface: {e}")) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | fn set_vsync(surface: &Surface<WindowSurface>, context: &PossiblyCurrentContext) { |
| 259 | if let Err(e) = surface.set_swap_interval( |
| 260 | context, |
| 261 | SwapInterval::Wait(std::num::NonZeroU32::new(1).expect("nonzero")), |
| 262 | ) { |
| 263 | eprintln!("vidya: vsync unavailable ({e})"); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | /// A tessellated frame waiting for the compositor to ask for it. |
| 268 | struct PaintJob { |
| 269 | primitives: Vec<egui::ClippedPrimitive>, |
| 270 | textures_delta: egui::TexturesDelta, |
| 271 | pixels_per_point: f32, |
| 272 | clear: [f32; 4], |
| 273 | } |
| 274 | |
| 275 | /// winit event sink. Owns everything that survives between frames. |
| 276 | struct Handler { |
| 277 | egui_ctx: egui::Context, |
| 278 | theme: Theme, |
| 279 | gl: Option<Gl>, |
| 280 | title: String, |
| 281 | width: u32, |
| 282 | height: u32, |
| 283 | should_close: bool, |
| 284 | /// Set when window creation fails, so `vidya_open` can report it. |
| 285 | error: Option<String>, |
| 286 | /// Whether the compositor is ready for another buffer. Starts open: the |
| 287 | /// initial configure entitles us to the first frame, and the frame callback |
| 288 | /// that arms every later one is only requested *by* presenting — waiting |
| 289 | /// for it before the first present deadlocks. |
| 290 | may_present: bool, |
| 291 | frames: u32, |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 292 | /// The size, in pixels, the frame now being built laid itself out against. |
| 293 | /// |
| 294 | /// A resize that lands *while* the caller walks its tree leaves the pass |
| 295 | /// measuring one size and the buffer another, and the strip between them |
| 296 | /// is painted with nothing but the clear colour — the charcoal band that |
| 297 | /// follows the edge being dragged. Comparing this with the window tells |
| 298 | /// the caller to walk the tree again before any of it is presented. |
| 299 | frame_dims: [u32; 2], |
| Bring vidya in cfd3e36 nandi 19d ago | 300 | /// `VIDYA_CAPTURE=<path>`: dump one painted frame, then stop. |
| 301 | capture: Option<String>, |
| 302 | /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order. |
| 303 | resize_at: Vec<(u32, f64, f64)>, |
| 304 | /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third, |
| Catch up with vidya c90f8af nandi 19d ago | 305 | /// for a screen that only exists once something has loaded. `ms:2500` says |
| 306 | /// when rather than which — a window nobody is compositing skips frames, so |
| 307 | /// a frame number can be a wait with no end on an unfocused desktop. |
| Bring vidya in cfd3e36 nandi 19d ago | 308 | capture_at: u32, |
| Catch up with vidya c90f8af nandi 19d ago | 309 | capture_after: Option<Duration>, |
| 310 | /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order. |
| 311 | click_at: Vec<(u32, f32, f32)>, |
| 312 | started: Instant, |
| Bring vidya in cfd3e36 nandi 19d ago | 313 | } |
| 314 | |
| 315 | impl Handler { |
| 316 | /// Paint and present. Only ever called from inside `RedrawRequested`. |
| 317 | fn present(&mut self, job: PaintJob) { |
| 318 | let Some(gl) = self.gl.as_mut() else { |
| 319 | return; |
| 320 | }; |
| 321 | // Between a suspend and the resume after it there is nothing to present |
| 322 | // to. The caller's loop is unaffected; it just paints no frames. |
| 323 | let Some(surface) = gl.surface.as_ref() else { |
| 324 | return; |
| 325 | }; |
| 326 | let size = gl.window.inner_size(); |
| 327 | let dims = [size.width.max(1), size.height.max(1)]; |
| 328 | |
| 329 | gl.painter.clear(dims, job.clear); |
| 330 | gl.painter.paint_and_update_textures( |
| 331 | dims, |
| 332 | job.pixels_per_point, |
| 333 | &job.primitives, |
| 334 | &job.textures_delta, |
| 335 | ); |
| 336 | |
| 337 | self.frames += 1; |
| 338 | // A resize the compositor never sent, for testing a layout that only |
| 339 | // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT` |
| 340 | // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag |
| 341 | // of the window's edge would. |
| 342 | for &(at, w, h) in &self.resize_at { |
| 343 | if self.frames == at { |
| 344 | let _ = gl |
| 345 | .window |
| 346 | .request_inner_size(winit::dpi::LogicalSize::new(w, h)); |
| 347 | } |
| 348 | } |
| 349 | // Third frame: fonts and layout have settled by then. |
| Catch up with vidya c90f8af nandi 19d ago | 350 | // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and |
| 351 | // releases the left button at a point, so an interaction can be tested |
| 352 | // where there is nobody to do the clicking. |
| 353 | for &(at, x, y) in &self.click_at { |
| 354 | if self.frames == at { |
| 355 | let pos = egui::pos2(x, y); |
| 356 | let events = &mut gl.winit_state.egui_input_mut().events; |
| 357 | events.push(egui::Event::PointerMoved(pos)); |
| 358 | for pressed in [true, false] { |
| 359 | events.push(egui::Event::PointerButton { |
| 360 | pos, |
| 361 | button: egui::PointerButton::Primary, |
| 362 | pressed, |
| 363 | modifiers: egui::Modifiers::default(), |
| 364 | }); |
| 365 | } |
| 366 | } |
| 367 | } |
| 368 | let due = match self.capture_after { |
| 369 | Some(after) => self.started.elapsed() >= after, |
| 370 | None => self.frames == self.capture_at, |
| 371 | }; |
| 372 | if due { |
| Bring vidya in cfd3e36 nandi 19d ago | 373 | if let Some(path) = self.capture.take() { |
| 374 | capture_frame(gl, dims, &path); |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | // Order matters. `pre_present_notify` lets winit attach its frame |
| 379 | // callback to the commit that `swap_buffers` is about to make, and the |
| 380 | // redraw request that arms the *next* callback only counts once that |
| 381 | // commit has happened. |
| 382 | gl.window.pre_present_notify(); |
| 383 | if let Err(e) = surface.swap_buffers(&gl.context) { |
| 384 | eprintln!("vidya: swap_buffers failed: {e}"); |
| 385 | } |
| 386 | gl.window.request_redraw(); |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | /// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke. |
| 391 | /// |
| 392 | /// egui-winit answers a paste shortcut by reading the clipboard's *text* and |
| 393 | /// pushing an `Event::Paste` with it — and returns there, pushing nothing at |
| 394 | /// all when the clipboard holds no text. A copied picture is exactly that |
| 395 | /// case, so the one gesture that means "paste this picture" was the one |
| 396 | /// gesture egui never heard about. |
| 397 | /// |
| 398 | /// The key event it would have pushed goes back in. Nothing in egui acts on a |
| 399 | /// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert |
| 400 | /// except to a caller that goes looking for it, which is what `:entry`'s |
| 401 | /// `paste-empty` does. |
| 402 | fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) { |
| 403 | let WindowEvent::KeyboardInput { event: key, .. } = event else { |
| 404 | return; |
| 405 | }; |
| 406 | if !key.state.is_pressed() { |
| 407 | return; |
| 408 | } |
| 409 | let modifiers = gl.winit_state.egui_input().modifiers; |
| 410 | if !modifiers.command { |
| 411 | return; |
| 412 | } |
| 413 | // Logical first, physical as the fallback: the same rule egui-winit uses, |
| 414 | // so a layout with no Latin V of its own still pastes from where V sits. |
| 415 | let logical_v = matches!( |
| 416 | &key.logical_key, |
| 417 | winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v") |
| 418 | ); |
| 419 | let physical_v = matches!( |
| 420 | key.physical_key, |
| 421 | winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV) |
| 422 | ); |
| 423 | if !logical_v && !physical_v { |
| 424 | return; |
| 425 | } |
| 426 | gl.winit_state |
| 427 | .egui_input_mut() |
| 428 | .events |
| 429 | .push(egui::Event::Key { |
| 430 | key: egui::Key::V, |
| 431 | physical_key: None, |
| 432 | pressed: true, |
| 433 | repeat: false, |
| 434 | modifiers, |
| 435 | }); |
| 436 | } |
| 437 | |
| 438 | impl ApplicationHandler for Handler { |
| 439 | fn resumed(&mut self, el: &ActiveEventLoop) { |
| 440 | if let Some(gl) = self.gl.as_mut() { |
| 441 | // Not a first start: the activity came back to the foreground, and |
| 442 | // what it lost while it was away was the surface. |
| 443 | gl.resume(); |
| 444 | // A window that stopped being composited stopped presenting too, |
| 445 | // and the redraw request that arms the next frame callback is only |
| 446 | // made *by* presenting. Nothing would ever ask for the first frame |
| 447 | // back without this. |
| 448 | self.may_present = true; |
| 449 | return; |
| 450 | } |
| 451 | match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) { |
| 452 | Ok(gl) => { |
| 453 | vidya_core::apply(&self.egui_ctx, &self.theme); |
| 454 | self.gl = Some(gl); |
| 455 | } |
| 456 | Err(e) => { |
| 457 | self.error = Some(e); |
| 458 | self.should_close = true; |
| 459 | } |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | /// The platform is taking the native window away — Android does this every |
| 464 | /// time the activity leaves the foreground. |
| 465 | fn suspended(&mut self, _el: &ActiveEventLoop) { |
| 466 | if let Some(gl) = self.gl.as_mut() { |
| 467 | gl.suspend(); |
| 468 | } |
| 469 | } |
| 470 | |
| 471 | fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { |
| 472 | if let Some(gl) = self.gl.as_mut() { |
| 473 | // egui sees every event, including the ones handled below. |
| 474 | let _ = gl.winit_state.on_window_event(&gl.window, &event); |
| 475 | note_paste_shortcut(gl, &event); |
| 476 | |
| 477 | if let WindowEvent::Resized(size) = event { |
| 478 | if size.width > 0 && size.height > 0 { |
| 479 | if let Some(surface) = gl.surface.as_ref() { |
| 480 | gl.window.resize_surface(surface, &gl.context); |
| 481 | } |
| 482 | } |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | match event { |
| 487 | WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true, |
| 488 | WindowEvent::RedrawRequested => self.may_present = true, |
| 489 | _ => {} |
| 490 | } |
| 491 | } |
| 492 | } |
| 493 | |
| 494 | /// One UI context per process, matching the ABI's window model. |
| 495 | pub struct App { |
| 496 | event_loop: EventLoop<()>, |
| 497 | handler: Handler, |
| 498 | /// Live only between `begin_frame` and `end_frame`. |
| 499 | pub stack: Stack, |
| 500 | frame_budget: Duration, |
| 501 | frame_started: Instant, |
| 502 | /// Frames skipped because nothing was compositing the window. |
| 503 | dropped: u32, |
| 504 | font_generation: u32, |
| 505 | } |
| 506 | |
| 507 | impl App { |
| 508 | pub fn open(width: i32, height: i32, title: &str) -> Result<Self, String> { |
| 509 | let event_loop = build_event_loop()?; |
| 510 | |
| 511 | let mut app = Self { |
| 512 | event_loop, |
| 513 | handler: Handler { |
| 514 | egui_ctx: egui::Context::default(), |
| 515 | theme: Theme::dark(), |
| 516 | gl: None, |
| 517 | title: title.to_owned(), |
| 518 | width: width.max(1) as u32, |
| 519 | height: height.max(1) as u32, |
| 520 | should_close: false, |
| 521 | error: None, |
| 522 | may_present: true, |
| 523 | frames: 0, |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 524 | frame_dims: [0, 0], |
| Bring vidya in cfd3e36 nandi 19d ago | 525 | capture: std::env::var("VIDYA_CAPTURE").ok(), |
| 526 | resize_at: std::env::var("VIDYA_RESIZE_AT") |
| 527 | .unwrap_or_default() |
| 528 | .split(',') |
| 529 | .filter_map(|step| { |
| 530 | let (at, size) = step.split_once(':')?; |
| 531 | let (w, h) = size.split_once('x')?; |
| 532 | Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?)) |
| 533 | }) |
| 534 | .collect(), |
| 535 | capture_at: std::env::var("VIDYA_CAPTURE_AT") |
| 536 | .ok() |
| 537 | .and_then(|v| v.parse().ok()) |
| 538 | .unwrap_or(3), |
| Catch up with vidya c90f8af nandi 19d ago | 539 | click_at: std::env::var("VIDYA_CLICK_AT") |
| 540 | .unwrap_or_default() |
| 541 | .split(';') |
| 542 | .filter_map(|step| { |
| 543 | let (at, point) = step.split_once(':')?; |
| 544 | let (x, y) = point.split_once(',')?; |
| 545 | Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?)) |
| 546 | }) |
| 547 | .collect(), |
| 548 | capture_after: std::env::var("VIDYA_CAPTURE_AT") |
| 549 | .ok() |
| 550 | .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok())) |
| 551 | .map(Duration::from_millis), |
| 552 | started: Instant::now(), |
| Bring vidya in cfd3e36 nandi 19d ago | 553 | }, |
| 554 | stack: Stack::default(), |
| 555 | frame_budget: DEFAULT_FRAME_BUDGET, |
| 556 | frame_started: Instant::now(), |
| 557 | dropped: 0, |
| 558 | font_generation: 0, |
| 559 | }; |
| 560 | |
| 561 | // Pump until the platform resumes us and the window exists. |
| 562 | let deadline = Instant::now() + OPEN_TIMEOUT; |
| 563 | while app.handler.gl.is_none() && app.handler.error.is_none() { |
| 564 | if Instant::now() > deadline { |
| 565 | return Err("timed out waiting for a window".to_owned()); |
| 566 | } |
| 567 | app.pump(Duration::from_millis(10)); |
| 568 | } |
| 569 | match app.handler.error.take() { |
| 570 | Some(e) => Err(e), |
| 571 | None => Ok(app), |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | fn pump(&mut self, timeout: Duration) { |
| 576 | self.event_loop |
| 577 | .pump_app_events(Some(timeout), &mut self.handler); |
| 578 | } |
| 579 | |
| 580 | pub fn should_close(&mut self) -> bool { |
| 581 | self.pump(Duration::ZERO); |
| 582 | self.handler.should_close |
| 583 | } |
| 584 | |
| 585 | pub fn theme(&self) -> &Theme { |
| 586 | &self.handler.theme |
| 587 | } |
| 588 | |
| 589 | /// The innermost open node plus the live theme. |
| 590 | /// |
| 591 | /// Returned together because widget calls need both, and they live in |
| 592 | /// different fields — splitting the borrow here keeps the call sites plain. |
| Tell a caller how big the window is 42dabb0 nandi 19d ago | 593 | /// The window's size in points, as egui last saw it. |
| 594 | /// |
| 595 | /// Points rather than pixels: a caller sizing something against this is |
| 596 | /// laying out, and layout is in the same units the widgets are. The |
| 597 | /// `width`/`height` the window was opened with are no answer — they are |
| 598 | /// what was asked for once, and say nothing about a window since dragged |
| 599 | /// wider. |
| 600 | /// |
| 601 | /// egui keeps the last screen rect on its context, so this answers between |
| 602 | /// frames as well as during one. Zero before the first frame, which is the |
| 603 | /// honest answer to asking how big a window is before it has been painted. |
| 604 | pub fn screen_size(&self) -> (f32, f32) { |
| 605 | let rect = self.handler.egui_ctx.screen_rect(); |
| 606 | if rect.width().is_finite() && rect.height().is_finite() { |
| 607 | (rect.width().max(0.0), rect.height().max(0.0)) |
| 608 | } else { |
| 609 | (0.0, 0.0) |
| 610 | } |
| 611 | } |
| 612 | |
| Bring vidya in cfd3e36 nandi 19d ago | 613 | pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> { |
| 614 | let theme = &self.handler.theme; |
| 615 | self.stack.top().map(|ui| (ui, theme)) |
| 616 | } |
| 617 | |
| 618 | pub fn set_mode(&mut self, mode: vidya_core::Mode) { |
| 619 | self.handler.theme = match mode { |
| 620 | vidya_core::Mode::Dark => Theme::dark(), |
| 621 | vidya_core::Mode::Light => Theme::light(), |
| 622 | }; |
| 623 | vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme); |
| 624 | } |
| 625 | |
| 626 | pub fn set_target_fps(&mut self, fps: i32) { |
| 627 | self.frame_budget = match fps { |
| 628 | f if f > 0 => Duration::from_secs_f64(1.0 / f as f64), |
| 629 | _ => DEFAULT_FRAME_BUDGET, |
| 630 | }; |
| 631 | } |
| 632 | |
| 633 | /// Install a UI font as the highest-priority proportional family. |
| 634 | /// |
| 635 | /// The symbol fallback installed by [`vidya_core::apply`] stays in place, |
| 636 | /// so punctuation and block art keep rendering. |
| 637 | pub fn load_font(&mut self, path: &str) -> bool { |
| 638 | let Ok(bytes) = std::fs::read(path) else { |
| 639 | return false; |
| 640 | }; |
| 641 | self.font_generation += 1; |
| 642 | // Unique name per load: egui skips a name it already has. |
| 643 | let name = format!("vidya-ui-{}", self.font_generation); |
| 644 | self.handler |
| 645 | .egui_ctx |
| 646 | .add_font(egui::epaint::text::FontInsert::new( |
| 647 | &name, |
| 648 | egui::FontData::from_owned(bytes), |
| 649 | vec![egui::epaint::text::InsertFontFamily { |
| 650 | family: egui::FontFamily::Proportional, |
| 651 | priority: egui::epaint::text::FontPriority::Highest, |
| 652 | }], |
| 653 | )); |
| 654 | true |
| 655 | } |
| 656 | |
| 657 | /// Drain pending input and open an egui pass with a root [`egui::Ui`]. |
| 658 | pub fn begin_frame(&mut self) { |
| 659 | self.pump(Duration::ZERO); |
| 660 | self.frame_started = Instant::now(); |
| 661 | |
| 662 | if self.handler.gl.is_none() || self.stack.is_active() { |
| 663 | // No window, or the caller skipped `vidya_end_frame`. |
| 664 | return; |
| 665 | } |
| 666 | let Some(gl) = self.handler.gl.as_mut() else { |
| 667 | return; |
| 668 | }; |
| 669 | |
| 670 | let input = gl.winit_state.take_egui_input(&gl.window); |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 671 | // The size that input reports the window to be, kept for |
| 672 | // `resized_mid_frame` to compare the window against once the caller |
| 673 | // has finished walking its tree. |
| 674 | let size = gl.window.inner_size(); |
| 675 | let dims = [size.width.max(1), size.height.max(1)]; |
| Bring vidya in cfd3e36 nandi 19d ago | 676 | let ctx = &self.handler.egui_ctx; |
| 677 | ctx.begin_pass(input); |
| 678 | self.stack.push_root(ctx); |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 679 | self.handler.frame_dims = dims; |
| 680 | } |
| 681 | |
| 682 | /// Whether the window changed size after this frame started laying out. |
| 683 | /// |
| 684 | /// True means the pass now open measured a window that no longer exists, |
| 685 | /// and presenting it would paint a smaller layout into a larger buffer. |
| 686 | /// The caller answers by discarding the pass and walking the tree again; |
| 687 | /// the tree is retained and holds no sizes of its own, so a second walk is |
| 688 | /// simply the same tree laid out against the window as it now is. |
| 689 | pub fn resized_mid_frame(&mut self) -> bool { |
| 690 | if !self.stack.is_active() { |
| 691 | return false; |
| 692 | } |
| 693 | self.pump(Duration::ZERO); |
| 694 | let Some(gl) = self.handler.gl.as_ref() else { |
| 695 | return false; |
| 696 | }; |
| 697 | let size = gl.window.inner_size(); |
| 698 | [size.width.max(1), size.height.max(1)] != self.handler.frame_dims |
| 699 | } |
| 700 | |
| 701 | /// Close the open pass and throw its output away, presenting nothing. |
| 702 | /// |
| 703 | /// egui has to be told a pass ended whether or not anybody wants what it |
| 704 | /// produced — leaving one open would make the next `begin_pass` panic — |
| 705 | /// so this ends it and drops the result on the floor. |
| 706 | pub fn discard_frame(&mut self) { |
| 707 | if !self.stack.is_active() { |
| 708 | return; |
| 709 | } |
| 710 | self.stack.unwind(); |
| 711 | let _ = self.handler.egui_ctx.end_pass(); |
| Bring vidya in cfd3e36 nandi 19d ago | 712 | } |
| 713 | |
| 714 | /// Close the pass, then hand the frame to the event loop to present. |
| 715 | pub fn end_frame(&mut self) { |
| 716 | if !self.stack.is_active() { |
| 717 | return; |
| 718 | } |
| 719 | // Close anything the caller left open (a missing `vidya_card_end`). |
| 720 | self.stack.unwind(); |
| 721 | if self.handler.gl.is_none() { |
| 722 | return; |
| 723 | } |
| 724 | |
| 725 | let egui::FullOutput { |
| 726 | platform_output, |
| 727 | textures_delta, |
| 728 | shapes, |
| 729 | pixels_per_point, |
| 730 | .. |
| 731 | } = self.handler.egui_ctx.end_pass(); |
| 732 | let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point); |
| 733 | // Gamma, not linear: `Painter::clear` hands these straight to |
| 734 | // `glClearColor` against an sRGB framebuffer, so a `Rgba::from` |
| 735 | // conversion here would be applied twice and the window would clear to |
| 736 | // near-black instead of the palette's charcoal. |
| 737 | let clear = self |
| 738 | .handler |
| 739 | .theme |
| 740 | .palette |
| 741 | .window_bg |
| 742 | .to_normalized_gamma_f32(); |
| 743 | |
| 744 | if let Some(gl) = self.handler.gl.as_mut() { |
| 745 | gl.winit_state |
| 746 | .handle_platform_output(&gl.window, platform_output); |
| 747 | } |
| 748 | |
| 749 | // Wait for the compositor to want a frame, dispatching events while we |
| 750 | // wait — never inside `swap_buffers`, which would park the thread that |
| 751 | // owes the compositor its replies and stall the session. This wait is |
| 752 | // also what paces the caller's loop to the display. |
| 753 | let deadline = Instant::now() + PRESENT_TIMEOUT; |
| 754 | while !self.handler.may_present && Instant::now() < deadline { |
| 755 | self.pump(Duration::from_millis(2)); |
| 756 | } |
| 757 | |
| 758 | if self.handler.may_present { |
| 759 | self.handler.may_present = false; |
| 760 | self.handler.present(PaintJob { |
| 761 | primitives, |
| 762 | textures_delta, |
| 763 | pixels_per_point, |
| 764 | clear, |
| 765 | }); |
| 766 | } else { |
| 767 | // Nobody is compositing this window (minimized, another workspace). |
| 768 | // Drop the frame rather than force a present that would block. |
| 769 | self.dropped += 1; |
| 770 | if self.dropped == 1 { |
| 771 | eprintln!("vidya: window is not being composited; dropping frames"); |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | // Floor for platforms that do not throttle presents at all. |
| 776 | if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) { |
| 777 | std::thread::sleep(left); |
| 778 | } |
| 779 | } |
| 780 | } |
| 781 | |
| 782 | impl Drop for App { |
| 783 | fn drop(&mut self) { |
| 784 | self.stack.unwind(); |
| 785 | if let Some(gl) = self.handler.gl.as_mut() { |
| 786 | gl.painter.destroy(); |
| 787 | } |
| 788 | } |
| 789 | } |
| 790 | |
| 791 | /// Write the painted framebuffer to a binary PPM. |
| 792 | /// |
| 793 | /// A rendering backend is otherwise unverifiable where the compositor refuses |
| 794 | /// screenshots, and in CI where there is nobody to look. Off unless |
| 795 | /// `VIDYA_CAPTURE` names a path. |
| 796 | fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) { |
| 797 | use glow::HasContext as _; |
| 798 | use std::io::Write as _; |
| 799 | |
| 800 | let [w, h] = dims; |
| 801 | let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; |
| 802 | unsafe { |
| 803 | // Drain the pipeline: the pixels need not exist yet. |
| 804 | gl.painter.gl().finish(); |
| 805 | gl.painter.gl().read_pixels( |
| 806 | 0, |
| 807 | 0, |
| 808 | w as i32, |
| 809 | h as i32, |
| 810 | glow::RGBA, |
| 811 | glow::UNSIGNED_BYTE, |
| 812 | glow::PixelPackData::Slice(Some(&mut rgba)), |
| 813 | ); |
| 814 | } |
| 815 | |
| 816 | let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32); |
| 817 | out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); |
| 818 | // GL origin is bottom-left; PPM is top-down. |
| 819 | for row in (0..h as usize).rev() { |
| 820 | let start = row * w as usize * 4; |
| 821 | for px in rgba[start..start + w as usize * 4].chunks_exact(4) { |
| 822 | out.extend_from_slice(&px[..3]); |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) { |
| 827 | Ok(()) => eprintln!("vidya: wrote {path}"), |
| 828 | Err(e) => eprintln!("vidya: capture failed: {e}"), |
| 829 | } |
| 830 | } |