nandi/jolt-nativepublic Fork 0
b1758f55a558c64297729c4c64d80d20e80773e7
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 · 830 lines · 31.5 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d 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,
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago292 /// 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 ago300 /// `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 ago305 /// 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 ago308 capture_at: u32,
Catch up with vidya c90f8af nandi 19d ago309 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 ago313}
314
315impl 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 ago350 // 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 ago373 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.
402fn 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
438impl 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.
495pub 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
507impl 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 ago524 frame_dims: [0, 0],
Bring vidya in cfd3e36 nandi 19d ago525 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 ago539 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 ago553 },
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 ago593 /// 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 ago613 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 ago671 // 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 ago676 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 ago679 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 ago712 }
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
782impl 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.
796fn 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}