//! Window, GL context, and the caller-driven frame loop. //! //! The C ABI is pull-style: the caller owns the loop and calls //! [`App::begin_frame`] / [`App::end_frame`] around its own widget calls. //! `eframe` inverts that — it owns the loop and calls the app — so this backend //! drives `winit` with `pump_app_events` and paints through `egui_glow`, //! keeping the ABI (and every consumer of it) unchanged. //! //! One thing does **not** get to move outside the event loop: the present. //! Wayland gives a surface one buffer per frame callback, and winit tracks //! those callbacks itself. Presenting from outside its `RedrawRequested` //! dispatch desynchronizes that bookkeeping — the compositor stops calling //! back, the next `swap_buffers` blocks inside EGL with the session waiting on //! it, and the whole desktop stalls for seconds. So [`App::end_frame`] hands //! the finished frame to [`Handler`] and pumps until it is painted from inside //! the callback, which is also what paces the caller's loop. use std::sync::Arc; use std::time::{Duration, Instant}; use egui::ViewportId; use glutin::config::{Config, ConfigTemplateBuilder}; use glutin::context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}; use glutin::display::GetGlDisplay as _; use glutin::prelude::*; use glutin::surface::{Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface}; use glutin_winit::{DisplayBuilder, GlWindow as _}; use vidya_core::Theme; use winit::application::ApplicationHandler; use winit::dpi::LogicalSize; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::platform::pump_events::EventLoopExtPumpEvents as _; use winit::raw_window_handle::HasWindowHandle as _; use winit::window::{Window, WindowId}; use crate::ui::Stack; /// How long `vidya_open` waits for the platform to hand us a window. const OPEN_TIMEOUT: Duration = Duration::from_secs(5); /// How long a finished frame waits for a frame callback before being dropped. /// A window nobody is compositing (minimized, on another workspace) simply /// stops presenting; the caller's loop keeps running. const PRESENT_TIMEOUT: Duration = Duration::from_millis(250); /// Pacing floor, so a caller that never sets a target FPS still yields. const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666); /// Build the event loop, preferring X11 where both backends exist. /// /// Native Wayland does not survive this ABI's shape: the caller owns the loop, /// so winit is driven with `pump_app_events`, and a surface driven that way /// stops receiving frame callbacks after its first commit — the window never /// gets a second frame and the session stalls behind it. Under X11 (XWayland /// included) presentation does not depend on those callbacks, and the same loop /// runs at full frame rate. Falls back to the default backend when X11 is /// unavailable, so a compositor without XWayland still gets a window. #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] fn build_event_loop() -> Result, String> { use winit::platform::x11::EventLoopBuilderExtX11 as _; if let Ok(el) = EventLoop::builder().with_x11().build() { return Ok(el); } eprintln!("vidya: X11 unavailable; falling back to Wayland (expect stalls)"); EventLoop::builder() .build() .map_err(|e| format!("event loop: {e}")) } /// Android has no display connection to choose: the activity already owns one, /// and winit reaches it through the handle the glue was started with. Without /// that handle there is no event loop to build at all, which is why /// `libvidya.so` is the NativeActivity's own library — see `android.rs`. #[cfg(target_os = "android")] fn build_event_loop() -> Result, String> { use winit::platform::android::EventLoopBuilderExtAndroid as _; let app = crate::android::android_app() .ok_or_else(|| "no AndroidApp: vidya_open ran outside android_main".to_owned())?; EventLoop::builder() .with_android_app(app) .build() .map_err(|e| format!("event loop: {e}")) } #[cfg(not(all(unix, not(target_os = "macos"))))] fn build_event_loop() -> Result, String> { EventLoop::builder() .build() .map_err(|e| format!("event loop: {e}")) } /// Window, GL surface, painter, and egui input translation. /// /// Created on the first `resumed`, the only point where winit guarantees a /// usable display connection on every platform. /// /// The surface is the one part that does not live as long as the rest. Android /// takes the native window away whenever the activity leaves the foreground and /// hands back a *new* one on the way in, so a surface built on the old one /// presents to nothing — a black window, with `swap_buffers` reporting nothing /// wrong. It is dropped on `suspended` and rebuilt on the next `resumed`; the /// context and its textures outlive both, which is why the app comes back with /// its fonts and images intact rather than reloading them. struct Gl { window: Window, /// `None` between `suspended` and the `resumed` that follows it. surface: Option>, /// Kept for that rebuild: a surface has to match the config its context /// was created against. config: Config, context: PossiblyCurrentContext, painter: egui_glow::Painter, winit_state: egui_winit::State, } impl Gl { fn create( el: &ActiveEventLoop, egui_ctx: &egui::Context, title: &str, width: u32, height: u32, ) -> Result { let attrs = Window::default_attributes() .with_title(title) .with_inner_size(LogicalSize::new(width as f64, height as f64)); let (window, config) = DisplayBuilder::new() .with_window_attributes(Some(attrs)) .build( el, ConfigTemplateBuilder::new().with_alpha_size(0), // egui does its own anti-aliasing; this just avoids picking a // degenerate config. |configs| { configs .reduce(|best, c| { if c.num_samples() > best.num_samples() { c } else { best } }) .expect("no GL config") }, ) .map_err(|e| format!("GL display: {e}"))?; let window = window.ok_or_else(|| "no window was created".to_owned())?; let raw = window .window_handle() .map_err(|e| format!("window handle: {e}"))? .as_raw(); let display = config.display(); // Desktop GL first, GLES second — the order eframe uses. let context = unsafe { display .create_context(&config, &ContextAttributesBuilder::new().build(Some(raw))) .or_else(|_| { display.create_context( &config, &ContextAttributesBuilder::new() .with_context_api(ContextApi::Gles(None)) .build(Some(raw)), ) }) .map_err(|e| format!("GL context: {e}"))? }; let surface = build_surface(&window, &config)?; let context = context .make_current(&surface) .map_err(|e| format!("make current: {e}"))?; set_vsync(&surface, &context); let glow_ctx = unsafe { glow::Context::from_loader_function_cstr(|s| display.get_proc_address(s).cast()) }; let painter = egui_glow::Painter::new(Arc::new(glow_ctx), "", None, false) .map_err(|e| format!("painter: {e}"))?; let winit_state = egui_winit::State::new( egui_ctx.clone(), ViewportId::ROOT, &window, None, window.theme(), Some(painter.max_texture_side()), ); Ok(Self { window, surface: Some(surface), config, context, painter, winit_state, }) } /// Give up the surface the platform is about to invalidate. The context is /// released from this thread first: destroying a surface that is still /// current is not something EGL owes an answer for. fn suspend(&mut self) { if self.surface.is_none() { return; } if let Err(e) = self.context.make_not_current_in_place() { eprintln!("vidya: releasing the GL context failed: {e}"); } self.surface = None; } /// Build a surface on whatever native window the platform has now, and make /// the context current on it again. A failure here leaves the surface /// `None`, so the loop keeps running and drops frames rather than painting /// into a surface that is not there. fn resume(&mut self) { if self.surface.is_some() { return; } let surface = match build_surface(&self.window, &self.config) { Ok(surface) => surface, Err(e) => { eprintln!("vidya: rebuilding the GL surface failed: {e}"); return; } }; if let Err(e) = self.context.make_current(&surface) { eprintln!("vidya: make current failed on resume: {e}"); return; } set_vsync(&surface, &self.context); // The new window is rarely the old one's size — a keyboard may have // gone away under it, or the device turned. self.window.request_redraw(); self.surface = Some(surface); } } fn build_surface(window: &Window, config: &Config) -> Result, String> { let attrs = window .build_surface_attributes(SurfaceAttributesBuilder::new()) .map_err(|e| format!("surface attributes: {e}"))?; // SAFETY: the attributes name this window, which outlives the surface. unsafe { config .display() .create_window_surface(config, &attrs) .map_err(|e| format!("GL surface: {e}")) } } fn set_vsync(surface: &Surface, context: &PossiblyCurrentContext) { if let Err(e) = surface.set_swap_interval( context, SwapInterval::Wait(std::num::NonZeroU32::new(1).expect("nonzero")), ) { eprintln!("vidya: vsync unavailable ({e})"); } } /// A tessellated frame waiting for the compositor to ask for it. struct PaintJob { primitives: Vec, textures_delta: egui::TexturesDelta, pixels_per_point: f32, clear: [f32; 4], } /// winit event sink. Owns everything that survives between frames. struct Handler { egui_ctx: egui::Context, theme: Theme, gl: Option, title: String, width: u32, height: u32, should_close: bool, /// Set when window creation fails, so `vidya_open` can report it. error: Option, /// Whether the compositor is ready for another buffer. Starts open: the /// initial configure entitles us to the first frame, and the frame callback /// that arms every later one is only requested *by* presenting — waiting /// for it before the first present deadlocks. may_present: bool, /// Whether the wait for the compositor already happened this frame. /// /// `end_frame` waits unless it did. That matters because the wait is where /// a drag's resizes arrive, and a caller that has already waited has also /// already checked its layout against the result — pumping again between /// that check and the present would let one more resize through, into the /// gap the check just proved empty. slot_waited: bool, frames: u32, /// The size, in pixels, the frame now being built laid itself out against. /// /// A resize that lands *while* the caller walks its tree leaves the pass /// measuring one size and the buffer another, and the strip between them /// is painted with nothing but the clear colour — the charcoal band that /// follows the edge being dragged. Comparing this with the window tells /// the caller to walk the tree again before any of it is presented. frame_dims: [u32; 2], /// `VIDYA_CAPTURE=`: dump one painted frame, then stop. capture: Option, /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order. resize_at: Vec<(u32, f64, f64)>, /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third, /// for a screen that only exists once something has loaded. `ms:2500` says /// when rather than which — a window nobody is compositing skips frames, so /// a frame number can be a wait with no end on an unfocused desktop. capture_at: u32, capture_after: Option, /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order. click_at: Vec<(u32, f32, f32)>, started: Instant, } impl Handler { /// Paint and present. Only ever called from inside `RedrawRequested`. fn present(&mut self, job: PaintJob) { let Some(gl) = self.gl.as_mut() else { return; }; // Between a suspend and the resume after it there is nothing to present // to. The caller's loop is unaffected; it just paints no frames. let Some(surface) = gl.surface.as_ref() else { return; }; let size = gl.window.inner_size(); let dims = [size.width.max(1), size.height.max(1)]; gl.painter.clear(dims, job.clear); gl.painter.paint_and_update_textures( dims, job.pixels_per_point, &job.primitives, &job.textures_delta, ); self.frames += 1; // Third frame: fonts and layout have settled by then. // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and // releases the left button at a point, so an interaction can be tested // where there is nobody to do the clicking. for &(at, x, y) in &self.click_at { if self.frames == at { let pos = egui::pos2(x, y); let events = &mut gl.winit_state.egui_input_mut().events; events.push(egui::Event::PointerMoved(pos)); for pressed in [true, false] { events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::default(), }); } } } let due = match self.capture_after { Some(after) => self.started.elapsed() >= after, None => self.frames == self.capture_at, }; if due { if let Some(path) = self.capture.take() { capture_frame(gl, dims, &path); } } // Order matters. `pre_present_notify` lets winit attach its frame // callback to the commit that `swap_buffers` is about to make, and the // redraw request that arms the *next* callback only counts once that // commit has happened. gl.window.pre_present_notify(); if let Err(e) = surface.swap_buffers(&gl.context) { eprintln!("vidya: swap_buffers failed: {e}"); } gl.window.request_redraw(); } } /// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke. /// /// egui-winit answers a paste shortcut by reading the clipboard's *text* and /// pushing an `Event::Paste` with it — and returns there, pushing nothing at /// all when the clipboard holds no text. A copied picture is exactly that /// case, so the one gesture that means "paste this picture" was the one /// gesture egui never heard about. /// /// The key event it would have pushed goes back in. Nothing in egui acts on a /// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert /// except to a caller that goes looking for it, which is what `:entry`'s /// `paste-empty` does. fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) { let WindowEvent::KeyboardInput { event: key, .. } = event else { return; }; if !key.state.is_pressed() { return; } let modifiers = gl.winit_state.egui_input().modifiers; if !modifiers.command { return; } // Logical first, physical as the fallback: the same rule egui-winit uses, // so a layout with no Latin V of its own still pastes from where V sits. let logical_v = matches!( &key.logical_key, winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v") ); let physical_v = matches!( key.physical_key, winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV) ); if !logical_v && !physical_v { return; } gl.winit_state .egui_input_mut() .events .push(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: true, repeat: false, modifiers, }); } impl ApplicationHandler for Handler { fn resumed(&mut self, el: &ActiveEventLoop) { if let Some(gl) = self.gl.as_mut() { // Not a first start: the activity came back to the foreground, and // what it lost while it was away was the surface. gl.resume(); // A window that stopped being composited stopped presenting too, // and the redraw request that arms the next frame callback is only // made *by* presenting. Nothing would ever ask for the first frame // back without this. self.may_present = true; return; } match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) { Ok(gl) => { vidya_core::apply(&self.egui_ctx, &self.theme); self.gl = Some(gl); } Err(e) => { self.error = Some(e); self.should_close = true; } } } /// The platform is taking the native window away — Android does this every /// time the activity leaves the foreground. fn suspended(&mut self, _el: &ActiveEventLoop) { if let Some(gl) = self.gl.as_mut() { gl.suspend(); } } fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { if let Some(gl) = self.gl.as_mut() { // egui sees every event, including the ones handled below. let _ = gl.winit_state.on_window_event(&gl.window, &event); note_paste_shortcut(gl, &event); if let WindowEvent::Resized(size) = event { if size.width > 0 && size.height > 0 { if let Some(surface) = gl.surface.as_ref() { gl.window.resize_surface(surface, &gl.context); } } } } match event { WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true, WindowEvent::RedrawRequested => self.may_present = true, _ => {} } } } /// One UI context per process, matching the ABI's window model. pub struct App { event_loop: EventLoop<()>, handler: Handler, /// Live only between `begin_frame` and `end_frame`. pub stack: Stack, frame_budget: Duration, frame_started: Instant, /// Frames skipped because nothing was compositing the window. dropped: u32, font_generation: u32, } impl App { pub fn open(width: i32, height: i32, title: &str) -> Result { let event_loop = build_event_loop()?; let mut app = Self { event_loop, handler: Handler { egui_ctx: egui::Context::default(), theme: Theme::dark(), gl: None, title: title.to_owned(), width: width.max(1) as u32, height: height.max(1) as u32, should_close: false, error: None, may_present: true, slot_waited: false, frames: 0, frame_dims: [0, 0], capture: std::env::var("VIDYA_CAPTURE").ok(), resize_at: std::env::var("VIDYA_RESIZE_AT") .unwrap_or_default() .split(',') .filter_map(|step| { let (at, size) = step.split_once(':')?; let (w, h) = size.split_once('x')?; Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?)) }) .collect(), capture_at: std::env::var("VIDYA_CAPTURE_AT") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(3), click_at: std::env::var("VIDYA_CLICK_AT") .unwrap_or_default() .split(';') .filter_map(|step| { let (at, point) = step.split_once(':')?; let (x, y) = point.split_once(',')?; Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?)) }) .collect(), capture_after: std::env::var("VIDYA_CAPTURE_AT") .ok() .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok())) .map(Duration::from_millis), started: Instant::now(), }, stack: Stack::default(), frame_budget: DEFAULT_FRAME_BUDGET, frame_started: Instant::now(), dropped: 0, font_generation: 0, }; // Pump until the platform resumes us and the window exists. let deadline = Instant::now() + OPEN_TIMEOUT; while app.handler.gl.is_none() && app.handler.error.is_none() { if Instant::now() > deadline { return Err("timed out waiting for a window".to_owned()); } app.pump(Duration::from_millis(10)); } match app.handler.error.take() { Some(e) => Err(e), None => Ok(app), } } fn pump(&mut self, timeout: Duration) { self.event_loop .pump_app_events(Some(timeout), &mut self.handler); } pub fn should_close(&mut self) -> bool { self.pump(Duration::ZERO); self.handler.should_close } pub fn theme(&self) -> &Theme { &self.handler.theme } /// The innermost open node plus the live theme. /// /// Returned together because widget calls need both, and they live in /// different fields — splitting the borrow here keeps the call sites plain. /// The window's size in points, as egui last saw it. /// /// Points rather than pixels: a caller sizing something against this is /// laying out, and layout is in the same units the widgets are. The /// `width`/`height` the window was opened with are no answer — they are /// what was asked for once, and say nothing about a window since dragged /// wider. /// /// egui keeps the last screen rect on its context, so this answers between /// frames as well as during one. Zero before the first frame, which is the /// honest answer to asking how big a window is before it has been painted. pub fn screen_size(&self) -> (f32, f32) { let rect = self.handler.egui_ctx.screen_rect(); if rect.width().is_finite() && rect.height().is_finite() { (rect.width().max(0.0), rect.height().max(0.0)) } else { (0.0, 0.0) } } pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> { let theme = &self.handler.theme; self.stack.top().map(|ui| (ui, theme)) } pub fn set_mode(&mut self, mode: vidya_core::Mode) { self.handler.theme = match mode { vidya_core::Mode::Dark => Theme::dark(), vidya_core::Mode::Light => Theme::light(), }; vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme); } /// Rename the open window. /// /// Onto the handler as well as the window: on Android the window is taken /// away and rebuilt whenever the activity leaves the foreground, and /// `Gl::create` names the new one from `self.title`. A title set once and /// only on the window would be the launch title again after a resume. pub fn set_title(&mut self, title: &str) { self.handler.title = title.to_owned(); if let Some(gl) = self.handler.gl.as_ref() { gl.window.set_title(title); } } pub fn set_target_fps(&mut self, fps: i32) { self.frame_budget = match fps { f if f > 0 => Duration::from_secs_f64(1.0 / f as f64), _ => DEFAULT_FRAME_BUDGET, }; } /// Install a UI font as the highest-priority proportional family. /// /// The symbol fallback installed by [`vidya_core::apply`] stays in place, /// so punctuation and block art keep rendering. pub fn load_font(&mut self, path: &str) -> bool { let Ok(bytes) = std::fs::read(path) else { return false; }; self.font_generation += 1; // Unique name per load: egui skips a name it already has. let name = format!("vidya-ui-{}", self.font_generation); self.handler .egui_ctx .add_font(egui::epaint::text::FontInsert::new( &name, egui::FontData::from_owned(bytes), vec![egui::epaint::text::InsertFontFamily { family: egui::FontFamily::Proportional, priority: egui::epaint::text::FontPriority::Highest, }], )); true } /// Drain pending input and open an egui pass with a root [`egui::Ui`]. pub fn begin_frame(&mut self) { self.pump(Duration::ZERO); self.frame_started = Instant::now(); // Per-frame, and cleared here rather than after a present: a frame // dropped because nobody was compositing never reaches one. self.handler.slot_waited = false; if self.handler.gl.is_none() || self.stack.is_active() { // No window, or the caller skipped `vidya_end_frame`. return; } let Some(gl) = self.handler.gl.as_mut() else { return; }; let input = gl.winit_state.take_egui_input(&gl.window); // The size that input reports the window to be, kept for // `resized_mid_frame` to compare the window against once the caller // has finished walking its tree. let size = gl.window.inner_size(); let dims = [size.width.max(1), size.height.max(1)]; // A resize the compositor never sent, for testing a layout that only // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT` // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag // of the window's edge would. // // Asked for here, after the input for this frame has been taken, so it // lands where a drag actually lands it: in the middle of the walk, // with the pass already measured against the old size. Requesting it // from `present` instead would only ever resize between frames, which // is the one case that was never a problem. let building = self.handler.frames + 1; for &(at, w, h) in &self.handler.resize_at { if building == at { let _ = gl .window .request_inner_size(winit::dpi::LogicalSize::new(w, h)); } } let ctx = &self.handler.egui_ctx; ctx.begin_pass(input); self.stack.push_root(ctx); self.handler.frame_dims = dims; } /// Wait for the compositor to want a frame, dispatching events while we /// wait — never inside `swap_buffers`, which would park the thread that /// owes the compositor its replies and stall the session. This wait is /// also what paces the caller's loop to the display. /// /// Callable before the pass is closed as well as from `end_frame`, and the /// second call is free. That is the point: the resizes a drag produces /// arrive *in here*, so a caller that wants to know whether its layout is /// still the right size has to wait first and ask afterwards. Asking /// before the wait sees a window that has not been told to move yet. pub fn await_present_slot(&mut self) { let deadline = Instant::now() + PRESENT_TIMEOUT; while !self.handler.may_present && Instant::now() < deadline { self.pump(Duration::from_millis(2)); } self.handler.slot_waited = true; } /// Whether the window changed size after this frame started laying out. /// /// True means the pass now open measured a window that no longer exists, /// and presenting it would paint a smaller layout into a larger buffer. /// The caller answers by discarding the pass and walking the tree again; /// the tree is retained and holds no sizes of its own, so a second walk is /// simply the same tree laid out against the window as it now is. pub fn resized_mid_frame(&mut self) -> bool { if !self.stack.is_active() { return false; } self.pump(Duration::ZERO); let Some(gl) = self.handler.gl.as_ref() else { return false; }; let size = gl.window.inner_size(); [size.width.max(1), size.height.max(1)] != self.handler.frame_dims } /// Close the open pass and throw its output away, presenting nothing. /// /// egui has to be told a pass ended whether or not anybody wants what it /// produced — leaving one open would make the next `begin_pass` panic — /// so this ends it and drops the result on the floor. pub fn discard_frame(&mut self) { if !self.stack.is_active() { return; } self.stack.unwind(); let _ = self.handler.egui_ctx.end_pass(); } /// Close the pass, then hand the frame to the event loop to present. pub fn end_frame(&mut self) { if !self.stack.is_active() { return; } // Close anything the caller left open (a missing `vidya_card_end`). self.stack.unwind(); if self.handler.gl.is_none() { return; } let egui::FullOutput { platform_output, textures_delta, shapes, pixels_per_point, .. } = self.handler.egui_ctx.end_pass(); let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point); // Gamma, not linear: `Painter::clear` hands these straight to // `glClearColor` against an sRGB framebuffer, so a `Rgba::from` // conversion here would be applied twice and the window would clear to // near-black instead of the palette's charcoal. let clear = self .handler .theme .palette .window_bg .to_normalized_gamma_f32(); if let Some(gl) = self.handler.gl.as_mut() { gl.winit_state .handle_platform_output(&gl.window, platform_output); } if !self.handler.slot_waited { self.await_present_slot(); } if self.handler.may_present { self.handler.may_present = false; self.handler.present(PaintJob { primitives, textures_delta, pixels_per_point, clear, }); } else { // Nobody is compositing this window (minimized, another workspace). // Drop the frame rather than force a present that would block. self.dropped += 1; if self.dropped == 1 { eprintln!("vidya: window is not being composited; dropping frames"); } } // Floor for platforms that do not throttle presents at all. if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) { std::thread::sleep(left); } } } impl Drop for App { fn drop(&mut self) { self.stack.unwind(); if let Some(gl) = self.handler.gl.as_mut() { gl.painter.destroy(); } } } /// Write the painted framebuffer to a binary PPM. /// /// A rendering backend is otherwise unverifiable where the compositor refuses /// screenshots, and in CI where there is nobody to look. Off unless /// `VIDYA_CAPTURE` names a path. fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) { use glow::HasContext as _; use std::io::Write as _; let [w, h] = dims; let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; unsafe { // Drain the pipeline: the pixels need not exist yet. gl.painter.gl().finish(); gl.painter.gl().read_pixels( 0, 0, w as i32, h as i32, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelPackData::Slice(Some(&mut rgba)), ); } let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32); out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); // GL origin is bottom-left; PPM is top-down. for row in (0..h as usize).rev() { let start = row * w as usize * 4; for px in rgba[start..start + w as usize * 4].chunks_exact(4) { out.extend_from_slice(&px[..3]); } } match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) { Ok(()) => eprintln!("vidya: wrote {path}"), Err(e) => eprintln!("vidya: capture failed: {e}"), } }