nandi/jolt-nativepublic Fork 0
e86c31ce657330246fa40ebee4ee37bbd4a02950
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

app.rs · 783 lines · 29.3 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 20d ago1//! 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
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use egui::ViewportId;
22use glutin::config::{Config, ConfigTemplateBuilder};
23use glutin::context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext};
24use glutin::display::GetGlDisplay as _;
25use glutin::prelude::*;
26use glutin::surface::{Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface};
27use glutin_winit::{DisplayBuilder, GlWindow as _};
28use vidya_core::Theme;
29use winit::application::ApplicationHandler;
30use winit::dpi::LogicalSize;
31use winit::event::WindowEvent;
32use winit::event_loop::{ActiveEventLoop, EventLoop};
33use winit::platform::pump_events::EventLoopExtPumpEvents as _;
34use winit::raw_window_handle::HasWindowHandle as _;
35use winit::window::{Window, WindowId};
36
37use crate::ui::Stack;
38
39/// How long `vidya_open` waits for the platform to hand us a window.
40const 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.
45const PRESENT_TIMEOUT: Duration = Duration::from_millis(250);
46
47/// Pacing floor, so a caller that never sets a target FPS still yields.
48const 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")))]
60fn 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")]
77fn 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"))))]
89fn 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.
107struct 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
119impl 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
245fn 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
258fn 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.
268struct 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.
276struct 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,
292 /// `VIDYA_CAPTURE=<path>`: dump one painted frame, then stop.
293 capture: Option<String>,
294 /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order.
295 resize_at: Vec<(u32, f64, f64)>,
296 /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third,
Catch up with vidya c90f8af nandi 20d ago297 /// for a screen that only exists once something has loaded. `ms:2500` says
298 /// when rather than which — a window nobody is compositing skips frames, so
299 /// a frame number can be a wait with no end on an unfocused desktop.
Bring vidya in cfd3e36 nandi 20d ago300 capture_at: u32,
Catch up with vidya c90f8af nandi 20d ago301 capture_after: Option<Duration>,
302 /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order.
303 click_at: Vec<(u32, f32, f32)>,
304 started: Instant,
Bring vidya in cfd3e36 nandi 20d ago305}
306
307impl Handler {
308 /// Paint and present. Only ever called from inside `RedrawRequested`.
309 fn present(&mut self, job: PaintJob) {
310 let Some(gl) = self.gl.as_mut() else {
311 return;
312 };
313 // Between a suspend and the resume after it there is nothing to present
314 // to. The caller's loop is unaffected; it just paints no frames.
315 let Some(surface) = gl.surface.as_ref() else {
316 return;
317 };
318 let size = gl.window.inner_size();
319 let dims = [size.width.max(1), size.height.max(1)];
320
321 gl.painter.clear(dims, job.clear);
322 gl.painter.paint_and_update_textures(
323 dims,
324 job.pixels_per_point,
325 &job.primitives,
326 &job.textures_delta,
327 );
328
329 self.frames += 1;
330 // A resize the compositor never sent, for testing a layout that only
331 // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT`
332 // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag
333 // of the window's edge would.
334 for &(at, w, h) in &self.resize_at {
335 if self.frames == at {
336 let _ = gl
337 .window
338 .request_inner_size(winit::dpi::LogicalSize::new(w, h));
339 }
340 }
341 // Third frame: fonts and layout have settled by then.
Catch up with vidya c90f8af nandi 20d ago342 // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and
343 // releases the left button at a point, so an interaction can be tested
344 // where there is nobody to do the clicking.
345 for &(at, x, y) in &self.click_at {
346 if self.frames == at {
347 let pos = egui::pos2(x, y);
348 let events = &mut gl.winit_state.egui_input_mut().events;
349 events.push(egui::Event::PointerMoved(pos));
350 for pressed in [true, false] {
351 events.push(egui::Event::PointerButton {
352 pos,
353 button: egui::PointerButton::Primary,
354 pressed,
355 modifiers: egui::Modifiers::default(),
356 });
357 }
358 }
359 }
360 let due = match self.capture_after {
361 Some(after) => self.started.elapsed() >= after,
362 None => self.frames == self.capture_at,
363 };
364 if due {
Bring vidya in cfd3e36 nandi 20d ago365 if let Some(path) = self.capture.take() {
366 capture_frame(gl, dims, &path);
367 }
368 }
369
370 // Order matters. `pre_present_notify` lets winit attach its frame
371 // callback to the commit that `swap_buffers` is about to make, and the
372 // redraw request that arms the *next* callback only counts once that
373 // commit has happened.
374 gl.window.pre_present_notify();
375 if let Err(e) = surface.swap_buffers(&gl.context) {
376 eprintln!("vidya: swap_buffers failed: {e}");
377 }
378 gl.window.request_redraw();
379 }
380}
381
382/// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke.
383///
384/// egui-winit answers a paste shortcut by reading the clipboard's *text* and
385/// pushing an `Event::Paste` with it — and returns there, pushing nothing at
386/// all when the clipboard holds no text. A copied picture is exactly that
387/// case, so the one gesture that means "paste this picture" was the one
388/// gesture egui never heard about.
389///
390/// The key event it would have pushed goes back in. Nothing in egui acts on a
391/// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert
392/// except to a caller that goes looking for it, which is what `:entry`'s
393/// `paste-empty` does.
394fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) {
395 let WindowEvent::KeyboardInput { event: key, .. } = event else {
396 return;
397 };
398 if !key.state.is_pressed() {
399 return;
400 }
401 let modifiers = gl.winit_state.egui_input().modifiers;
402 if !modifiers.command {
403 return;
404 }
405 // Logical first, physical as the fallback: the same rule egui-winit uses,
406 // so a layout with no Latin V of its own still pastes from where V sits.
407 let logical_v = matches!(
408 &key.logical_key,
409 winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v")
410 );
411 let physical_v = matches!(
412 key.physical_key,
413 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV)
414 );
415 if !logical_v && !physical_v {
416 return;
417 }
418 gl.winit_state
419 .egui_input_mut()
420 .events
421 .push(egui::Event::Key {
422 key: egui::Key::V,
423 physical_key: None,
424 pressed: true,
425 repeat: false,
426 modifiers,
427 });
428}
429
430impl ApplicationHandler for Handler {
431 fn resumed(&mut self, el: &ActiveEventLoop) {
432 if let Some(gl) = self.gl.as_mut() {
433 // Not a first start: the activity came back to the foreground, and
434 // what it lost while it was away was the surface.
435 gl.resume();
436 // A window that stopped being composited stopped presenting too,
437 // and the redraw request that arms the next frame callback is only
438 // made *by* presenting. Nothing would ever ask for the first frame
439 // back without this.
440 self.may_present = true;
441 return;
442 }
443 match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) {
444 Ok(gl) => {
445 vidya_core::apply(&self.egui_ctx, &self.theme);
446 self.gl = Some(gl);
447 }
448 Err(e) => {
449 self.error = Some(e);
450 self.should_close = true;
451 }
452 }
453 }
454
455 /// The platform is taking the native window away — Android does this every
456 /// time the activity leaves the foreground.
457 fn suspended(&mut self, _el: &ActiveEventLoop) {
458 if let Some(gl) = self.gl.as_mut() {
459 gl.suspend();
460 }
461 }
462
463 fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
464 if let Some(gl) = self.gl.as_mut() {
465 // egui sees every event, including the ones handled below.
466 let _ = gl.winit_state.on_window_event(&gl.window, &event);
467 note_paste_shortcut(gl, &event);
468
469 if let WindowEvent::Resized(size) = event {
470 if size.width > 0 && size.height > 0 {
471 if let Some(surface) = gl.surface.as_ref() {
472 gl.window.resize_surface(surface, &gl.context);
473 }
474 }
475 }
476 }
477
478 match event {
479 WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true,
480 WindowEvent::RedrawRequested => self.may_present = true,
481 _ => {}
482 }
483 }
484}
485
486/// One UI context per process, matching the ABI's window model.
487pub struct App {
488 event_loop: EventLoop<()>,
489 handler: Handler,
490 /// Live only between `begin_frame` and `end_frame`.
491 pub stack: Stack,
492 frame_budget: Duration,
493 frame_started: Instant,
494 /// Frames skipped because nothing was compositing the window.
495 dropped: u32,
496 font_generation: u32,
497}
498
499impl App {
500 pub fn open(width: i32, height: i32, title: &str) -> Result<Self, String> {
501 let event_loop = build_event_loop()?;
502
503 let mut app = Self {
504 event_loop,
505 handler: Handler {
506 egui_ctx: egui::Context::default(),
507 theme: Theme::dark(),
508 gl: None,
509 title: title.to_owned(),
510 width: width.max(1) as u32,
511 height: height.max(1) as u32,
512 should_close: false,
513 error: None,
514 may_present: true,
515 frames: 0,
516 capture: std::env::var("VIDYA_CAPTURE").ok(),
517 resize_at: std::env::var("VIDYA_RESIZE_AT")
518 .unwrap_or_default()
519 .split(',')
520 .filter_map(|step| {
521 let (at, size) = step.split_once(':')?;
522 let (w, h) = size.split_once('x')?;
523 Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?))
524 })
525 .collect(),
526 capture_at: std::env::var("VIDYA_CAPTURE_AT")
527 .ok()
528 .and_then(|v| v.parse().ok())
529 .unwrap_or(3),
Catch up with vidya c90f8af nandi 20d ago530 click_at: std::env::var("VIDYA_CLICK_AT")
531 .unwrap_or_default()
532 .split(';')
533 .filter_map(|step| {
534 let (at, point) = step.split_once(':')?;
535 let (x, y) = point.split_once(',')?;
536 Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?))
537 })
538 .collect(),
539 capture_after: std::env::var("VIDYA_CAPTURE_AT")
540 .ok()
541 .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok()))
542 .map(Duration::from_millis),
543 started: Instant::now(),
Bring vidya in cfd3e36 nandi 20d ago544 },
545 stack: Stack::default(),
546 frame_budget: DEFAULT_FRAME_BUDGET,
547 frame_started: Instant::now(),
548 dropped: 0,
549 font_generation: 0,
550 };
551
552 // Pump until the platform resumes us and the window exists.
553 let deadline = Instant::now() + OPEN_TIMEOUT;
554 while app.handler.gl.is_none() && app.handler.error.is_none() {
555 if Instant::now() > deadline {
556 return Err("timed out waiting for a window".to_owned());
557 }
558 app.pump(Duration::from_millis(10));
559 }
560 match app.handler.error.take() {
561 Some(e) => Err(e),
562 None => Ok(app),
563 }
564 }
565
566 fn pump(&mut self, timeout: Duration) {
567 self.event_loop
568 .pump_app_events(Some(timeout), &mut self.handler);
569 }
570
571 pub fn should_close(&mut self) -> bool {
572 self.pump(Duration::ZERO);
573 self.handler.should_close
574 }
575
576 pub fn theme(&self) -> &Theme {
577 &self.handler.theme
578 }
579
580 /// The innermost open node plus the live theme.
581 ///
582 /// Returned together because widget calls need both, and they live in
583 /// different fields — splitting the borrow here keeps the call sites plain.
Tell a caller how big the window is 42dabb0 nandi 20d ago584 /// The window's size in points, as egui last saw it.
585 ///
586 /// Points rather than pixels: a caller sizing something against this is
587 /// laying out, and layout is in the same units the widgets are. The
588 /// `width`/`height` the window was opened with are no answer — they are
589 /// what was asked for once, and say nothing about a window since dragged
590 /// wider.
591 ///
592 /// egui keeps the last screen rect on its context, so this answers between
593 /// frames as well as during one. Zero before the first frame, which is the
594 /// honest answer to asking how big a window is before it has been painted.
595 pub fn screen_size(&self) -> (f32, f32) {
596 let rect = self.handler.egui_ctx.screen_rect();
597 if rect.width().is_finite() && rect.height().is_finite() {
598 (rect.width().max(0.0), rect.height().max(0.0))
599 } else {
600 (0.0, 0.0)
601 }
602 }
603
Bring vidya in cfd3e36 nandi 20d ago604 pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> {
605 let theme = &self.handler.theme;
606 self.stack.top().map(|ui| (ui, theme))
607 }
608
609 pub fn set_mode(&mut self, mode: vidya_core::Mode) {
610 self.handler.theme = match mode {
611 vidya_core::Mode::Dark => Theme::dark(),
612 vidya_core::Mode::Light => Theme::light(),
613 };
614 vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme);
615 }
616
617 pub fn set_target_fps(&mut self, fps: i32) {
618 self.frame_budget = match fps {
619 f if f > 0 => Duration::from_secs_f64(1.0 / f as f64),
620 _ => DEFAULT_FRAME_BUDGET,
621 };
622 }
623
624 /// Install a UI font as the highest-priority proportional family.
625 ///
626 /// The symbol fallback installed by [`vidya_core::apply`] stays in place,
627 /// so punctuation and block art keep rendering.
628 pub fn load_font(&mut self, path: &str) -> bool {
629 let Ok(bytes) = std::fs::read(path) else {
630 return false;
631 };
632 self.font_generation += 1;
633 // Unique name per load: egui skips a name it already has.
634 let name = format!("vidya-ui-{}", self.font_generation);
635 self.handler
636 .egui_ctx
637 .add_font(egui::epaint::text::FontInsert::new(
638 &name,
639 egui::FontData::from_owned(bytes),
640 vec![egui::epaint::text::InsertFontFamily {
641 family: egui::FontFamily::Proportional,
642 priority: egui::epaint::text::FontPriority::Highest,
643 }],
644 ));
645 true
646 }
647
648 /// Drain pending input and open an egui pass with a root [`egui::Ui`].
649 pub fn begin_frame(&mut self) {
650 self.pump(Duration::ZERO);
651 self.frame_started = Instant::now();
652
653 if self.handler.gl.is_none() || self.stack.is_active() {
654 // No window, or the caller skipped `vidya_end_frame`.
655 return;
656 }
657 let Some(gl) = self.handler.gl.as_mut() else {
658 return;
659 };
660
661 let input = gl.winit_state.take_egui_input(&gl.window);
662 let ctx = &self.handler.egui_ctx;
663 ctx.begin_pass(input);
664 self.stack.push_root(ctx);
665 }
666
667 /// Close the pass, then hand the frame to the event loop to present.
668 pub fn end_frame(&mut self) {
669 if !self.stack.is_active() {
670 return;
671 }
672 // Close anything the caller left open (a missing `vidya_card_end`).
673 self.stack.unwind();
674 if self.handler.gl.is_none() {
675 return;
676 }
677
678 let egui::FullOutput {
679 platform_output,
680 textures_delta,
681 shapes,
682 pixels_per_point,
683 ..
684 } = self.handler.egui_ctx.end_pass();
685 let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point);
686 // Gamma, not linear: `Painter::clear` hands these straight to
687 // `glClearColor` against an sRGB framebuffer, so a `Rgba::from`
688 // conversion here would be applied twice and the window would clear to
689 // near-black instead of the palette's charcoal.
690 let clear = self
691 .handler
692 .theme
693 .palette
694 .window_bg
695 .to_normalized_gamma_f32();
696
697 if let Some(gl) = self.handler.gl.as_mut() {
698 gl.winit_state
699 .handle_platform_output(&gl.window, platform_output);
700 }
701
702 // Wait for the compositor to want a frame, dispatching events while we
703 // wait — never inside `swap_buffers`, which would park the thread that
704 // owes the compositor its replies and stall the session. This wait is
705 // also what paces the caller's loop to the display.
706 let deadline = Instant::now() + PRESENT_TIMEOUT;
707 while !self.handler.may_present && Instant::now() < deadline {
708 self.pump(Duration::from_millis(2));
709 }
710
711 if self.handler.may_present {
712 self.handler.may_present = false;
713 self.handler.present(PaintJob {
714 primitives,
715 textures_delta,
716 pixels_per_point,
717 clear,
718 });
719 } else {
720 // Nobody is compositing this window (minimized, another workspace).
721 // Drop the frame rather than force a present that would block.
722 self.dropped += 1;
723 if self.dropped == 1 {
724 eprintln!("vidya: window is not being composited; dropping frames");
725 }
726 }
727
728 // Floor for platforms that do not throttle presents at all.
729 if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {
730 std::thread::sleep(left);
731 }
732 }
733}
734
735impl Drop for App {
736 fn drop(&mut self) {
737 self.stack.unwind();
738 if let Some(gl) = self.handler.gl.as_mut() {
739 gl.painter.destroy();
740 }
741 }
742}
743
744/// Write the painted framebuffer to a binary PPM.
745///
746/// A rendering backend is otherwise unverifiable where the compositor refuses
747/// screenshots, and in CI where there is nobody to look. Off unless
748/// `VIDYA_CAPTURE` names a path.
749fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) {
750 use glow::HasContext as _;
751 use std::io::Write as _;
752
753 let [w, h] = dims;
754 let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
755 unsafe {
756 // Drain the pipeline: the pixels need not exist yet.
757 gl.painter.gl().finish();
758 gl.painter.gl().read_pixels(
759 0,
760 0,
761 w as i32,
762 h as i32,
763 glow::RGBA,
764 glow::UNSIGNED_BYTE,
765 glow::PixelPackData::Slice(Some(&mut rgba)),
766 );
767 }
768
769 let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32);
770 out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
771 // GL origin is bottom-left; PPM is top-down.
772 for row in (0..h as usize).rev() {
773 let start = row * w as usize * 4;
774 for px in rgba[start..start + w as usize * 4].chunks_exact(4) {
775 out.extend_from_slice(&px[..3]);
776 }
777 }
778
779 match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) {
780 Ok(()) => eprintln!("vidya: wrote {path}"),
781 Err(e) => eprintln!("vidya: capture failed: {e}"),
782 }
783}