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