nandi/jolt-nativepublic Fork 0
8cf1b3347ef6fa83297b6866dfd47d2728d4e339
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 · 876 lines · 33.6 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,
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago291 /// Whether the wait for the compositor already happened this frame.
292 ///
293 /// `end_frame` waits unless it did. That matters because the wait is where
294 /// a drag's resizes arrive, and a caller that has already waited has also
295 /// already checked its layout against the result — pumping again between
296 /// that check and the present would let one more resize through, into the
297 /// gap the check just proved empty.
298 slot_waited: bool,
Bring vidya in cfd3e36 nandi 19d ago299 frames: u32,
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago300 /// The size, in pixels, the frame now being built laid itself out against.
301 ///
302 /// A resize that lands *while* the caller walks its tree leaves the pass
303 /// measuring one size and the buffer another, and the strip between them
304 /// is painted with nothing but the clear colour — the charcoal band that
305 /// follows the edge being dragged. Comparing this with the window tells
306 /// the caller to walk the tree again before any of it is presented.
307 frame_dims: [u32; 2],
Bring vidya in cfd3e36 nandi 19d ago308 /// `VIDYA_CAPTURE=<path>`: dump one painted frame, then stop.
309 capture: Option<String>,
310 /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order.
311 resize_at: Vec<(u32, f64, f64)>,
312 /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third,
Catch up with vidya c90f8af nandi 19d ago313 /// for a screen that only exists once something has loaded. `ms:2500` says
314 /// when rather than which — a window nobody is compositing skips frames, so
315 /// a frame number can be a wait with no end on an unfocused desktop.
Bring vidya in cfd3e36 nandi 19d ago316 capture_at: u32,
Catch up with vidya c90f8af nandi 19d ago317 capture_after: Option<Duration>,
318 /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order.
319 click_at: Vec<(u32, f32, f32)>,
320 started: Instant,
Bring vidya in cfd3e36 nandi 19d ago321}
322
323impl Handler {
324 /// Paint and present. Only ever called from inside `RedrawRequested`.
325 fn present(&mut self, job: PaintJob) {
326 let Some(gl) = self.gl.as_mut() else {
327 return;
328 };
329 // Between a suspend and the resume after it there is nothing to present
330 // to. The caller's loop is unaffected; it just paints no frames.
331 let Some(surface) = gl.surface.as_ref() else {
332 return;
333 };
334 let size = gl.window.inner_size();
335 let dims = [size.width.max(1), size.height.max(1)];
336
337 gl.painter.clear(dims, job.clear);
338 gl.painter.paint_and_update_textures(
339 dims,
340 job.pixels_per_point,
341 &job.primitives,
342 &job.textures_delta,
343 );
344
345 self.frames += 1;
346 // Third frame: fonts and layout have settled by then.
Catch up with vidya c90f8af nandi 19d ago347 // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and
348 // releases the left button at a point, so an interaction can be tested
349 // where there is nobody to do the clicking.
350 for &(at, x, y) in &self.click_at {
351 if self.frames == at {
352 let pos = egui::pos2(x, y);
353 let events = &mut gl.winit_state.egui_input_mut().events;
354 events.push(egui::Event::PointerMoved(pos));
355 for pressed in [true, false] {
356 events.push(egui::Event::PointerButton {
357 pos,
358 button: egui::PointerButton::Primary,
359 pressed,
360 modifiers: egui::Modifiers::default(),
361 });
362 }
363 }
364 }
365 let due = match self.capture_after {
366 Some(after) => self.started.elapsed() >= after,
367 None => self.frames == self.capture_at,
368 };
369 if due {
Bring vidya in cfd3e36 nandi 19d ago370 if let Some(path) = self.capture.take() {
371 capture_frame(gl, dims, &path);
372 }
373 }
374
375 // Order matters. `pre_present_notify` lets winit attach its frame
376 // callback to the commit that `swap_buffers` is about to make, and the
377 // redraw request that arms the *next* callback only counts once that
378 // commit has happened.
379 gl.window.pre_present_notify();
380 if let Err(e) = surface.swap_buffers(&gl.context) {
381 eprintln!("vidya: swap_buffers failed: {e}");
382 }
383 gl.window.request_redraw();
384 }
385}
386
387/// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke.
388///
389/// egui-winit answers a paste shortcut by reading the clipboard's *text* and
390/// pushing an `Event::Paste` with it — and returns there, pushing nothing at
391/// all when the clipboard holds no text. A copied picture is exactly that
392/// case, so the one gesture that means "paste this picture" was the one
393/// gesture egui never heard about.
394///
395/// The key event it would have pushed goes back in. Nothing in egui acts on a
396/// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert
397/// except to a caller that goes looking for it, which is what `:entry`'s
398/// `paste-empty` does.
399fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) {
400 let WindowEvent::KeyboardInput { event: key, .. } = event else {
401 return;
402 };
403 if !key.state.is_pressed() {
404 return;
405 }
406 let modifiers = gl.winit_state.egui_input().modifiers;
407 if !modifiers.command {
408 return;
409 }
410 // Logical first, physical as the fallback: the same rule egui-winit uses,
411 // so a layout with no Latin V of its own still pastes from where V sits.
412 let logical_v = matches!(
413 &key.logical_key,
414 winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v")
415 );
416 let physical_v = matches!(
417 key.physical_key,
418 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV)
419 );
420 if !logical_v && !physical_v {
421 return;
422 }
423 gl.winit_state
424 .egui_input_mut()
425 .events
426 .push(egui::Event::Key {
427 key: egui::Key::V,
428 physical_key: None,
429 pressed: true,
430 repeat: false,
431 modifiers,
432 });
433}
434
435impl ApplicationHandler for Handler {
436 fn resumed(&mut self, el: &ActiveEventLoop) {
437 if let Some(gl) = self.gl.as_mut() {
438 // Not a first start: the activity came back to the foreground, and
439 // what it lost while it was away was the surface.
440 gl.resume();
441 // A window that stopped being composited stopped presenting too,
442 // and the redraw request that arms the next frame callback is only
443 // made *by* presenting. Nothing would ever ask for the first frame
444 // back without this.
445 self.may_present = true;
446 return;
447 }
448 match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) {
449 Ok(gl) => {
450 vidya_core::apply(&self.egui_ctx, &self.theme);
451 self.gl = Some(gl);
452 }
453 Err(e) => {
454 self.error = Some(e);
455 self.should_close = true;
456 }
457 }
458 }
459
460 /// The platform is taking the native window away — Android does this every
461 /// time the activity leaves the foreground.
462 fn suspended(&mut self, _el: &ActiveEventLoop) {
463 if let Some(gl) = self.gl.as_mut() {
464 gl.suspend();
465 }
466 }
467
468 fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
469 if let Some(gl) = self.gl.as_mut() {
470 // egui sees every event, including the ones handled below.
471 let _ = gl.winit_state.on_window_event(&gl.window, &event);
472 note_paste_shortcut(gl, &event);
473
474 if let WindowEvent::Resized(size) = event {
475 if size.width > 0 && size.height > 0 {
476 if let Some(surface) = gl.surface.as_ref() {
477 gl.window.resize_surface(surface, &gl.context);
478 }
479 }
480 }
481 }
482
483 match event {
484 WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true,
485 WindowEvent::RedrawRequested => self.may_present = true,
486 _ => {}
487 }
488 }
489}
490
491/// One UI context per process, matching the ABI's window model.
492pub struct App {
493 event_loop: EventLoop<()>,
494 handler: Handler,
495 /// Live only between `begin_frame` and `end_frame`.
496 pub stack: Stack,
497 frame_budget: Duration,
498 frame_started: Instant,
499 /// Frames skipped because nothing was compositing the window.
500 dropped: u32,
501 font_generation: u32,
502}
503
504impl App {
505 pub fn open(width: i32, height: i32, title: &str) -> Result<Self, String> {
506 let event_loop = build_event_loop()?;
507
508 let mut app = Self {
509 event_loop,
510 handler: Handler {
511 egui_ctx: egui::Context::default(),
512 theme: Theme::dark(),
513 gl: None,
514 title: title.to_owned(),
515 width: width.max(1) as u32,
516 height: height.max(1) as u32,
517 should_close: false,
518 error: None,
519 may_present: true,
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago520 slot_waited: false,
Bring vidya in cfd3e36 nandi 19d ago521 frames: 0,
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago522 frame_dims: [0, 0],
Bring vidya in cfd3e36 nandi 19d ago523 capture: std::env::var("VIDYA_CAPTURE").ok(),
524 resize_at: std::env::var("VIDYA_RESIZE_AT")
525 .unwrap_or_default()
526 .split(',')
527 .filter_map(|step| {
528 let (at, size) = step.split_once(':')?;
529 let (w, h) = size.split_once('x')?;
530 Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?))
531 })
532 .collect(),
533 capture_at: std::env::var("VIDYA_CAPTURE_AT")
534 .ok()
535 .and_then(|v| v.parse().ok())
536 .unwrap_or(3),
Catch up with vidya c90f8af nandi 19d ago537 click_at: std::env::var("VIDYA_CLICK_AT")
538 .unwrap_or_default()
539 .split(';')
540 .filter_map(|step| {
541 let (at, point) = step.split_once(':')?;
542 let (x, y) = point.split_once(',')?;
543 Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?))
544 })
545 .collect(),
546 capture_after: std::env::var("VIDYA_CAPTURE_AT")
547 .ok()
548 .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok()))
549 .map(Duration::from_millis),
550 started: Instant::now(),
Bring vidya in cfd3e36 nandi 19d ago551 },
552 stack: Stack::default(),
553 frame_budget: DEFAULT_FRAME_BUDGET,
554 frame_started: Instant::now(),
555 dropped: 0,
556 font_generation: 0,
557 };
558
559 // Pump until the platform resumes us and the window exists.
560 let deadline = Instant::now() + OPEN_TIMEOUT;
561 while app.handler.gl.is_none() && app.handler.error.is_none() {
562 if Instant::now() > deadline {
563 return Err("timed out waiting for a window".to_owned());
564 }
565 app.pump(Duration::from_millis(10));
566 }
567 match app.handler.error.take() {
568 Some(e) => Err(e),
569 None => Ok(app),
570 }
571 }
572
573 fn pump(&mut self, timeout: Duration) {
574 self.event_loop
575 .pump_app_events(Some(timeout), &mut self.handler);
576 }
577
578 pub fn should_close(&mut self) -> bool {
579 self.pump(Duration::ZERO);
580 self.handler.should_close
581 }
582
583 pub fn theme(&self) -> &Theme {
584 &self.handler.theme
585 }
586
587 /// The innermost open node plus the live theme.
588 ///
589 /// Returned together because widget calls need both, and they live in
590 /// different fields — splitting the borrow here keeps the call sites plain.
Tell a caller how big the window is 42dabb0 nandi 19d ago591 /// The window's size in points, as egui last saw it.
592 ///
593 /// Points rather than pixels: a caller sizing something against this is
594 /// laying out, and layout is in the same units the widgets are. The
595 /// `width`/`height` the window was opened with are no answer — they are
596 /// what was asked for once, and say nothing about a window since dragged
597 /// wider.
598 ///
599 /// egui keeps the last screen rect on its context, so this answers between
600 /// frames as well as during one. Zero before the first frame, which is the
601 /// honest answer to asking how big a window is before it has been painted.
602 pub fn screen_size(&self) -> (f32, f32) {
603 let rect = self.handler.egui_ctx.screen_rect();
604 if rect.width().is_finite() && rect.height().is_finite() {
605 (rect.width().max(0.0), rect.height().max(0.0))
606 } else {
607 (0.0, 0.0)
608 }
609 }
610
Bring vidya in cfd3e36 nandi 19d ago611 pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> {
612 let theme = &self.handler.theme;
613 self.stack.top().map(|ui| (ui, theme))
614 }
615
616 pub fn set_mode(&mut self, mode: vidya_core::Mode) {
617 self.handler.theme = match mode {
618 vidya_core::Mode::Dark => Theme::dark(),
619 vidya_core::Mode::Light => Theme::light(),
620 };
621 vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme);
622 }
623
Let the window be renamed after it opens 5adddc7 nandi 19d ago624 /// Rename the open window.
625 ///
626 /// Onto the handler as well as the window: on Android the window is taken
627 /// away and rebuilt whenever the activity leaves the foreground, and
628 /// `Gl::create` names the new one from `self.title`. A title set once and
629 /// only on the window would be the launch title again after a resume.
630 pub fn set_title(&mut self, title: &str) {
631 self.handler.title = title.to_owned();
632 if let Some(gl) = self.handler.gl.as_ref() {
633 gl.window.set_title(title);
634 }
635 }
636
Bring vidya in cfd3e36 nandi 19d ago637 pub fn set_target_fps(&mut self, fps: i32) {
638 self.frame_budget = match fps {
639 f if f > 0 => Duration::from_secs_f64(1.0 / f as f64),
640 _ => DEFAULT_FRAME_BUDGET,
641 };
642 }
643
644 /// Install a UI font as the highest-priority proportional family.
645 ///
646 /// The symbol fallback installed by [`vidya_core::apply`] stays in place,
647 /// so punctuation and block art keep rendering.
648 pub fn load_font(&mut self, path: &str) -> bool {
649 let Ok(bytes) = std::fs::read(path) else {
650 return false;
651 };
652 self.font_generation += 1;
653 // Unique name per load: egui skips a name it already has.
654 let name = format!("vidya-ui-{}", self.font_generation);
655 self.handler
656 .egui_ctx
657 .add_font(egui::epaint::text::FontInsert::new(
658 &name,
659 egui::FontData::from_owned(bytes),
660 vec![egui::epaint::text::InsertFontFamily {
661 family: egui::FontFamily::Proportional,
662 priority: egui::epaint::text::FontPriority::Highest,
663 }],
664 ));
665 true
666 }
667
668 /// Drain pending input and open an egui pass with a root [`egui::Ui`].
669 pub fn begin_frame(&mut self) {
670 self.pump(Duration::ZERO);
671 self.frame_started = Instant::now();
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago672 // Per-frame, and cleared here rather than after a present: a frame
673 // dropped because nobody was compositing never reaches one.
674 self.handler.slot_waited = false;
Bring vidya in cfd3e36 nandi 19d ago675
676 if self.handler.gl.is_none() || self.stack.is_active() {
677 // No window, or the caller skipped `vidya_end_frame`.
678 return;
679 }
680 let Some(gl) = self.handler.gl.as_mut() else {
681 return;
682 };
683
684 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 ago685 // The size that input reports the window to be, kept for
686 // `resized_mid_frame` to compare the window against once the caller
687 // has finished walking its tree.
688 let size = gl.window.inner_size();
689 let dims = [size.width.max(1), size.height.max(1)];
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago690 // A resize the compositor never sent, for testing a layout that only
691 // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT`
692 // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag
693 // of the window's edge would.
694 //
695 // Asked for here, after the input for this frame has been taken, so it
696 // lands where a drag actually lands it: in the middle of the walk,
697 // with the pass already measured against the old size. Requesting it
698 // from `present` instead would only ever resize between frames, which
699 // is the one case that was never a problem.
700 let building = self.handler.frames + 1;
701 for &(at, w, h) in &self.handler.resize_at {
702 if building == at {
703 let _ = gl
704 .window
705 .request_inner_size(winit::dpi::LogicalSize::new(w, h));
706 }
707 }
708
Bring vidya in cfd3e36 nandi 19d ago709 let ctx = &self.handler.egui_ctx;
710 ctx.begin_pass(input);
711 self.stack.push_root(ctx);
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago712 self.handler.frame_dims = dims;
713 }
714
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago715 /// Wait for the compositor to want a frame, dispatching events while we
716 /// wait — never inside `swap_buffers`, which would park the thread that
717 /// owes the compositor its replies and stall the session. This wait is
718 /// also what paces the caller's loop to the display.
719 ///
720 /// Callable before the pass is closed as well as from `end_frame`, and the
721 /// second call is free. That is the point: the resizes a drag produces
722 /// arrive *in here*, so a caller that wants to know whether its layout is
723 /// still the right size has to wait first and ask afterwards. Asking
724 /// before the wait sees a window that has not been told to move yet.
725 pub fn await_present_slot(&mut self) {
726 let deadline = Instant::now() + PRESENT_TIMEOUT;
727 while !self.handler.may_present && Instant::now() < deadline {
728 self.pump(Duration::from_millis(2));
729 }
730 self.handler.slot_waited = true;
731 }
732
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago733 /// Whether the window changed size after this frame started laying out.
734 ///
735 /// True means the pass now open measured a window that no longer exists,
736 /// and presenting it would paint a smaller layout into a larger buffer.
737 /// The caller answers by discarding the pass and walking the tree again;
738 /// the tree is retained and holds no sizes of its own, so a second walk is
739 /// simply the same tree laid out against the window as it now is.
740 pub fn resized_mid_frame(&mut self) -> bool {
741 if !self.stack.is_active() {
742 return false;
743 }
744 self.pump(Duration::ZERO);
745 let Some(gl) = self.handler.gl.as_ref() else {
746 return false;
747 };
748 let size = gl.window.inner_size();
Take the resize scaffolding back out 2ab40bf nandi 19d ago749 [size.width.max(1), size.height.max(1)] != self.handler.frame_dims
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago750 }
751
752 /// Close the open pass and throw its output away, presenting nothing.
753 ///
754 /// egui has to be told a pass ended whether or not anybody wants what it
755 /// produced — leaving one open would make the next `begin_pass` panic —
756 /// so this ends it and drops the result on the floor.
757 pub fn discard_frame(&mut self) {
758 if !self.stack.is_active() {
759 return;
760 }
761 self.stack.unwind();
762 let _ = self.handler.egui_ctx.end_pass();
Bring vidya in cfd3e36 nandi 19d ago763 }
764
765 /// Close the pass, then hand the frame to the event loop to present.
766 pub fn end_frame(&mut self) {
767 if !self.stack.is_active() {
768 return;
769 }
770 // Close anything the caller left open (a missing `vidya_card_end`).
771 self.stack.unwind();
772 if self.handler.gl.is_none() {
773 return;
774 }
775
776 let egui::FullOutput {
777 platform_output,
778 textures_delta,
779 shapes,
780 pixels_per_point,
781 ..
782 } = self.handler.egui_ctx.end_pass();
783 let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point);
784 // Gamma, not linear: `Painter::clear` hands these straight to
785 // `glClearColor` against an sRGB framebuffer, so a `Rgba::from`
786 // conversion here would be applied twice and the window would clear to
787 // near-black instead of the palette's charcoal.
788 let clear = self
789 .handler
790 .theme
791 .palette
792 .window_bg
793 .to_normalized_gamma_f32();
794
795 if let Some(gl) = self.handler.gl.as_mut() {
796 gl.winit_state
797 .handle_platform_output(&gl.window, platform_output);
798 }
799
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago800 if !self.handler.slot_waited {
801 self.await_present_slot();
Bring vidya in cfd3e36 nandi 19d ago802 }
803
804 if self.handler.may_present {
805 self.handler.may_present = false;
806 self.handler.present(PaintJob {
807 primitives,
808 textures_delta,
809 pixels_per_point,
810 clear,
811 });
812 } else {
813 // Nobody is compositing this window (minimized, another workspace).
814 // Drop the frame rather than force a present that would block.
815 self.dropped += 1;
816 if self.dropped == 1 {
817 eprintln!("vidya: window is not being composited; dropping frames");
818 }
819 }
820
821 // Floor for platforms that do not throttle presents at all.
822 if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {
823 std::thread::sleep(left);
824 }
825 }
826}
827
828impl Drop for App {
829 fn drop(&mut self) {
830 self.stack.unwind();
831 if let Some(gl) = self.handler.gl.as_mut() {
832 gl.painter.destroy();
833 }
834 }
835}
836
837/// Write the painted framebuffer to a binary PPM.
838///
839/// A rendering backend is otherwise unverifiable where the compositor refuses
840/// screenshots, and in CI where there is nobody to look. Off unless
841/// `VIDYA_CAPTURE` names a path.
842fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) {
843 use glow::HasContext as _;
844 use std::io::Write as _;
845
846 let [w, h] = dims;
847 let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
848 unsafe {
849 // Drain the pipeline: the pixels need not exist yet.
850 gl.painter.gl().finish();
851 gl.painter.gl().read_pixels(
852 0,
853 0,
854 w as i32,
855 h as i32,
856 glow::RGBA,
857 glow::UNSIGNED_BYTE,
858 glow::PixelPackData::Slice(Some(&mut rgba)),
859 );
860 }
861
862 let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32);
863 out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
864 // GL origin is bottom-left; PPM is top-down.
865 for row in (0..h as usize).rev() {
866 let start = row * w as usize * 4;
867 for px in rgba[start..start + w as usize * 4].chunks_exact(4) {
868 out.extend_from_slice(&px[..3]);
869 }
870 }
871
872 match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) {
873 Ok(()) => eprintln!("vidya: wrote {path}"),
874 Err(e) => eprintln!("vidya: capture failed: {e}"),
875 }
876}