nandi/jolt-nativepublic Fork 0
356d73fda588d31d8a5891e1b597361725c16be8
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 · 934 lines · 36.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
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago50/// Build the event loop: X11 (XWayland included) unless asked otherwise.
Bring vidya in cfd3e36 nandi 19d ago51///
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago52/// Native Wayland does not survive this ABI's shape. The caller owns the loop,
Bring vidya in cfd3e36 nandi 19d ago53/// so winit is driven with `pump_app_events`, and a surface driven that way
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago54/// receives exactly one frame callback: the configure. Presenting inside
55/// winit's own `RedrawRequested` dispatch is not enough to keep them coming —
56/// measured, every later `request_redraw` goes unanswered and each frame waits
57/// out `PRESENT_TIMEOUT` instead. Under X11 presentation depends on no such
58/// callback, and the same loop runs at the display's rate.
59///
60/// There is exactly one attempt to spend, which is why this chooses rather than
61/// tries: winit marks the process as having built an event loop even when the
62/// build *fails*, so the old try-X11-then-fall-back arrangement could not fall
63/// back — the second build answered "EventLoop can't be recreated", and a
64/// session with no XWayland got no window at all rather than the Wayland one it
65/// was promised.
66///
67/// `VIDYA_BACKEND=wayland` asks for the native surface anyway, for whoever is
68/// fixing the above; `x11` forces X11 where `WAYLAND_DISPLAY` is set but
69/// `DISPLAY` is what works.
Bring vidya in cfd3e36 nandi 19d ago70#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))]
71fn build_event_loop() -> Result<EventLoop<()>, String> {
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago72 use winit::platform::wayland::EventLoopBuilderExtWayland as _;
Bring vidya in cfd3e36 nandi 19d ago73 use winit::platform::x11::EventLoopBuilderExtX11 as _;
74
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago75 let wayland = match std::env::var("VIDYA_BACKEND").unwrap_or_default().as_str() {
76 "wayland" => true,
77 // X11 unless there is no X server to reach at all, in which case a
78 // window that stalls beats no window.
79 _ => std::env::var_os("DISPLAY").is_none(),
80 };
81
82 let built = if wayland {
83 EventLoop::builder().with_wayland().build()
84 } else {
85 EventLoop::builder().with_x11().build()
86 };
87 built.map_err(|e| {
88 let backend = if wayland { "wayland" } else { "x11" };
89 format!("event loop ({backend}): {e} — VIDYA_BACKEND selects the other")
90 })
Bring vidya in cfd3e36 nandi 19d ago91}
92
93/// Android has no display connection to choose: the activity already owns one,
94/// and winit reaches it through the handle the glue was started with. Without
95/// that handle there is no event loop to build at all, which is why
96/// `libvidya.so` is the NativeActivity's own library — see `android.rs`.
97#[cfg(target_os = "android")]
98fn build_event_loop() -> Result<EventLoop<()>, String> {
99 use winit::platform::android::EventLoopBuilderExtAndroid as _;
100
101 let app = crate::android::android_app()
102 .ok_or_else(|| "no AndroidApp: vidya_open ran outside android_main".to_owned())?;
103 EventLoop::builder()
104 .with_android_app(app)
105 .build()
106 .map_err(|e| format!("event loop: {e}"))
107}
108
109#[cfg(not(all(unix, not(target_os = "macos"))))]
110fn build_event_loop() -> Result<EventLoop<()>, String> {
111 EventLoop::builder()
112 .build()
113 .map_err(|e| format!("event loop: {e}"))
114}
115
116/// Window, GL surface, painter, and egui input translation.
117///
118/// Created on the first `resumed`, the only point where winit guarantees a
119/// usable display connection on every platform.
120///
121/// The surface is the one part that does not live as long as the rest. Android
122/// takes the native window away whenever the activity leaves the foreground and
123/// hands back a *new* one on the way in, so a surface built on the old one
124/// presents to nothing — a black window, with `swap_buffers` reporting nothing
125/// wrong. It is dropped on `suspended` and rebuilt on the next `resumed`; the
126/// context and its textures outlive both, which is why the app comes back with
127/// its fonts and images intact rather than reloading them.
128struct Gl {
129 window: Window,
130 /// `None` between `suspended` and the `resumed` that follows it.
131 surface: Option<Surface<WindowSurface>>,
132 /// Kept for that rebuild: a surface has to match the config its context
133 /// was created against.
134 config: Config,
135 context: PossiblyCurrentContext,
136 painter: egui_glow::Painter,
137 winit_state: egui_winit::State,
138}
139
140impl Gl {
141 fn create(
142 el: &ActiveEventLoop,
143 egui_ctx: &egui::Context,
144 title: &str,
145 width: u32,
146 height: u32,
147 ) -> Result<Self, String> {
148 let attrs = Window::default_attributes()
149 .with_title(title)
150 .with_inner_size(LogicalSize::new(width as f64, height as f64));
151
152 let (window, config) = DisplayBuilder::new()
153 .with_window_attributes(Some(attrs))
154 .build(
155 el,
156 ConfigTemplateBuilder::new().with_alpha_size(0),
157 // egui does its own anti-aliasing; this just avoids picking a
158 // degenerate config.
159 |configs| {
160 configs
161 .reduce(|best, c| {
162 if c.num_samples() > best.num_samples() {
163 c
164 } else {
165 best
166 }
167 })
168 .expect("no GL config")
169 },
170 )
171 .map_err(|e| format!("GL display: {e}"))?;
172 let window = window.ok_or_else(|| "no window was created".to_owned())?;
173
174 let raw = window
175 .window_handle()
176 .map_err(|e| format!("window handle: {e}"))?
177 .as_raw();
178 let display = config.display();
179
180 // Desktop GL first, GLES second — the order eframe uses.
181 let context = unsafe {
182 display
183 .create_context(&config, &ContextAttributesBuilder::new().build(Some(raw)))
184 .or_else(|_| {
185 display.create_context(
186 &config,
187 &ContextAttributesBuilder::new()
188 .with_context_api(ContextApi::Gles(None))
189 .build(Some(raw)),
190 )
191 })
192 .map_err(|e| format!("GL context: {e}"))?
193 };
194
195 let surface = build_surface(&window, &config)?;
196 let context = context
197 .make_current(&surface)
198 .map_err(|e| format!("make current: {e}"))?;
199 set_vsync(&surface, &context);
200
201 let glow_ctx = unsafe {
202 glow::Context::from_loader_function_cstr(|s| display.get_proc_address(s).cast())
203 };
204 let painter = egui_glow::Painter::new(Arc::new(glow_ctx), "", None, false)
205 .map_err(|e| format!("painter: {e}"))?;
206
207 let winit_state = egui_winit::State::new(
208 egui_ctx.clone(),
209 ViewportId::ROOT,
210 &window,
211 None,
212 window.theme(),
213 Some(painter.max_texture_side()),
214 );
215
216 Ok(Self {
217 window,
218 surface: Some(surface),
219 config,
220 context,
221 painter,
222 winit_state,
223 })
224 }
225
226 /// Give up the surface the platform is about to invalidate. The context is
227 /// released from this thread first: destroying a surface that is still
228 /// current is not something EGL owes an answer for.
229 fn suspend(&mut self) {
230 if self.surface.is_none() {
231 return;
232 }
233 if let Err(e) = self.context.make_not_current_in_place() {
234 eprintln!("vidya: releasing the GL context failed: {e}");
235 }
236 self.surface = None;
237 }
238
239 /// Build a surface on whatever native window the platform has now, and make
240 /// the context current on it again. A failure here leaves the surface
241 /// `None`, so the loop keeps running and drops frames rather than painting
242 /// into a surface that is not there.
243 fn resume(&mut self) {
244 if self.surface.is_some() {
245 return;
246 }
247 let surface = match build_surface(&self.window, &self.config) {
248 Ok(surface) => surface,
249 Err(e) => {
250 eprintln!("vidya: rebuilding the GL surface failed: {e}");
251 return;
252 }
253 };
254 if let Err(e) = self.context.make_current(&surface) {
255 eprintln!("vidya: make current failed on resume: {e}");
256 return;
257 }
258 set_vsync(&surface, &self.context);
259 // The new window is rarely the old one's size — a keyboard may have
260 // gone away under it, or the device turned.
261 self.window.request_redraw();
262 self.surface = Some(surface);
263 }
264}
265
266fn build_surface(window: &Window, config: &Config) -> Result<Surface<WindowSurface>, String> {
267 let attrs = window
268 .build_surface_attributes(SurfaceAttributesBuilder::new())
269 .map_err(|e| format!("surface attributes: {e}"))?;
270 // SAFETY: the attributes name this window, which outlives the surface.
271 unsafe {
272 config
273 .display()
274 .create_window_surface(config, &attrs)
275 .map_err(|e| format!("GL surface: {e}"))
276 }
277}
278
279fn set_vsync(surface: &Surface<WindowSurface>, context: &PossiblyCurrentContext) {
280 if let Err(e) = surface.set_swap_interval(
281 context,
282 SwapInterval::Wait(std::num::NonZeroU32::new(1).expect("nonzero")),
283 ) {
284 eprintln!("vidya: vsync unavailable ({e})");
285 }
286}
287
288/// A tessellated frame waiting for the compositor to ask for it.
289struct PaintJob {
290 primitives: Vec<egui::ClippedPrimitive>,
291 textures_delta: egui::TexturesDelta,
292 pixels_per_point: f32,
293 clear: [f32; 4],
294}
295
296/// winit event sink. Owns everything that survives between frames.
297struct Handler {
298 egui_ctx: egui::Context,
299 theme: Theme,
300 gl: Option<Gl>,
301 title: String,
302 width: u32,
303 height: u32,
304 should_close: bool,
305 /// Set when window creation fails, so `vidya_open` can report it.
306 error: Option<String>,
307 /// Whether the compositor is ready for another buffer. Starts open: the
308 /// initial configure entitles us to the first frame, and the frame callback
309 /// that arms every later one is only requested *by* presenting — waiting
310 /// for it before the first present deadlocks.
311 may_present: bool,
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago312 /// Whether the wait for the compositor already happened this frame.
313 ///
314 /// `end_frame` waits unless it did. That matters because the wait is where
315 /// a drag's resizes arrive, and a caller that has already waited has also
316 /// already checked its layout against the result — pumping again between
317 /// that check and the present would let one more resize through, into the
318 /// gap the check just proved empty.
319 slot_waited: bool,
Bring vidya in cfd3e36 nandi 19d ago320 frames: u32,
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago321 /// The size, in pixels, the frame now being built laid itself out against.
322 ///
323 /// A resize that lands *while* the caller walks its tree leaves the pass
324 /// measuring one size and the buffer another, and the strip between them
325 /// is painted with nothing but the clear colour — the charcoal band that
326 /// follows the edge being dragged. Comparing this with the window tells
327 /// the caller to walk the tree again before any of it is presented.
328 frame_dims: [u32; 2],
Bring vidya in cfd3e36 nandi 19d ago329 /// `VIDYA_CAPTURE=<path>`: dump one painted frame, then stop.
330 capture: Option<String>,
331 /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order.
332 resize_at: Vec<(u32, f64, f64)>,
333 /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third,
Catch up with vidya c90f8af nandi 19d ago334 /// for a screen that only exists once something has loaded. `ms:2500` says
335 /// when rather than which — a window nobody is compositing skips frames, so
336 /// a frame number can be a wait with no end on an unfocused desktop.
Bring vidya in cfd3e36 nandi 19d ago337 capture_at: u32,
Catch up with vidya c90f8af nandi 19d ago338 capture_after: Option<Duration>,
339 /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order.
340 click_at: Vec<(u32, f32, f32)>,
341 started: Instant,
Bring vidya in cfd3e36 nandi 19d ago342}
343
344impl Handler {
345 /// Paint and present. Only ever called from inside `RedrawRequested`.
346 fn present(&mut self, job: PaintJob) {
347 let Some(gl) = self.gl.as_mut() else {
348 return;
349 };
350 // Between a suspend and the resume after it there is nothing to present
351 // to. The caller's loop is unaffected; it just paints no frames.
352 let Some(surface) = gl.surface.as_ref() else {
353 return;
354 };
355 let size = gl.window.inner_size();
356 let dims = [size.width.max(1), size.height.max(1)];
357
358 gl.painter.clear(dims, job.clear);
359 gl.painter.paint_and_update_textures(
360 dims,
361 job.pixels_per_point,
362 &job.primitives,
363 &job.textures_delta,
364 );
365
366 self.frames += 1;
367 // Third frame: fonts and layout have settled by then.
Catch up with vidya c90f8af nandi 19d ago368 // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and
369 // releases the left button at a point, so an interaction can be tested
370 // where there is nobody to do the clicking.
371 for &(at, x, y) in &self.click_at {
372 if self.frames == at {
373 let pos = egui::pos2(x, y);
374 let events = &mut gl.winit_state.egui_input_mut().events;
375 events.push(egui::Event::PointerMoved(pos));
376 for pressed in [true, false] {
377 events.push(egui::Event::PointerButton {
378 pos,
379 button: egui::PointerButton::Primary,
380 pressed,
381 modifiers: egui::Modifiers::default(),
382 });
383 }
384 }
385 }
386 let due = match self.capture_after {
387 Some(after) => self.started.elapsed() >= after,
388 None => self.frames == self.capture_at,
389 };
390 if due {
Bring vidya in cfd3e36 nandi 19d ago391 if let Some(path) = self.capture.take() {
392 capture_frame(gl, dims, &path);
393 }
394 }
395
396 // Order matters. `pre_present_notify` lets winit attach its frame
397 // callback to the commit that `swap_buffers` is about to make, and the
398 // redraw request that arms the *next* callback only counts once that
399 // commit has happened.
400 gl.window.pre_present_notify();
401 if let Err(e) = surface.swap_buffers(&gl.context) {
402 eprintln!("vidya: swap_buffers failed: {e}");
403 }
404 gl.window.request_redraw();
405 }
406}
407
408/// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke.
409///
410/// egui-winit answers a paste shortcut by reading the clipboard's *text* and
411/// pushing an `Event::Paste` with it — and returns there, pushing nothing at
412/// all when the clipboard holds no text. A copied picture is exactly that
413/// case, so the one gesture that means "paste this picture" was the one
414/// gesture egui never heard about.
415///
416/// The key event it would have pushed goes back in. Nothing in egui acts on a
417/// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert
418/// except to a caller that goes looking for it, which is what `:entry`'s
419/// `paste-empty` does.
420fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) {
421 let WindowEvent::KeyboardInput { event: key, .. } = event else {
422 return;
423 };
424 if !key.state.is_pressed() {
425 return;
426 }
427 let modifiers = gl.winit_state.egui_input().modifiers;
428 if !modifiers.command {
429 return;
430 }
431 // Logical first, physical as the fallback: the same rule egui-winit uses,
432 // so a layout with no Latin V of its own still pastes from where V sits.
433 let logical_v = matches!(
434 &key.logical_key,
435 winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v")
436 );
437 let physical_v = matches!(
438 key.physical_key,
439 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV)
440 );
441 if !logical_v && !physical_v {
442 return;
443 }
444 gl.winit_state
445 .egui_input_mut()
446 .events
447 .push(egui::Event::Key {
448 key: egui::Key::V,
449 physical_key: None,
450 pressed: true,
451 repeat: false,
452 modifiers,
453 });
454}
455
456impl ApplicationHandler for Handler {
457 fn resumed(&mut self, el: &ActiveEventLoop) {
458 if let Some(gl) = self.gl.as_mut() {
459 // Not a first start: the activity came back to the foreground, and
460 // what it lost while it was away was the surface.
461 gl.resume();
462 // A window that stopped being composited stopped presenting too,
463 // and the redraw request that arms the next frame callback is only
464 // made *by* presenting. Nothing would ever ask for the first frame
465 // back without this.
466 self.may_present = true;
467 return;
468 }
469 match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) {
470 Ok(gl) => {
471 vidya_core::apply(&self.egui_ctx, &self.theme);
472 self.gl = Some(gl);
473 }
474 Err(e) => {
475 self.error = Some(e);
476 self.should_close = true;
477 }
478 }
479 }
480
481 /// The platform is taking the native window away — Android does this every
482 /// time the activity leaves the foreground.
483 fn suspended(&mut self, _el: &ActiveEventLoop) {
484 if let Some(gl) = self.gl.as_mut() {
485 gl.suspend();
486 }
487 }
488
489 fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
490 if let Some(gl) = self.gl.as_mut() {
491 // egui sees every event, including the ones handled below.
492 let _ = gl.winit_state.on_window_event(&gl.window, &event);
493 note_paste_shortcut(gl, &event);
494
495 if let WindowEvent::Resized(size) = event {
496 if size.width > 0 && size.height > 0 {
497 if let Some(surface) = gl.surface.as_ref() {
498 gl.window.resize_surface(surface, &gl.context);
499 }
500 }
501 }
502 }
503
504 match event {
505 WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true,
506 WindowEvent::RedrawRequested => self.may_present = true,
507 _ => {}
508 }
509 }
510}
511
512/// One UI context per process, matching the ABI's window model.
513pub struct App {
514 event_loop: EventLoop<()>,
515 handler: Handler,
516 /// Live only between `begin_frame` and `end_frame`.
517 pub stack: Stack,
518 frame_budget: Duration,
519 frame_started: Instant,
520 /// Frames skipped because nothing was compositing the window.
521 dropped: u32,
522 font_generation: u32,
523}
524
525impl App {
526 pub fn open(width: i32, height: i32, title: &str) -> Result<Self, String> {
527 let event_loop = build_event_loop()?;
528
529 let mut app = Self {
530 event_loop,
531 handler: Handler {
532 egui_ctx: egui::Context::default(),
533 theme: Theme::dark(),
534 gl: None,
535 title: title.to_owned(),
536 width: width.max(1) as u32,
537 height: height.max(1) as u32,
538 should_close: false,
539 error: None,
540 may_present: true,
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago541 slot_waited: false,
Bring vidya in cfd3e36 nandi 19d ago542 frames: 0,
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago543 frame_dims: [0, 0],
Bring vidya in cfd3e36 nandi 19d ago544 capture: std::env::var("VIDYA_CAPTURE").ok(),
545 resize_at: std::env::var("VIDYA_RESIZE_AT")
546 .unwrap_or_default()
547 .split(',')
548 .filter_map(|step| {
549 let (at, size) = step.split_once(':')?;
550 let (w, h) = size.split_once('x')?;
551 Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?))
552 })
553 .collect(),
554 capture_at: std::env::var("VIDYA_CAPTURE_AT")
555 .ok()
556 .and_then(|v| v.parse().ok())
557 .unwrap_or(3),
Catch up with vidya c90f8af nandi 19d ago558 click_at: std::env::var("VIDYA_CLICK_AT")
559 .unwrap_or_default()
560 .split(';')
561 .filter_map(|step| {
562 let (at, point) = step.split_once(':')?;
563 let (x, y) = point.split_once(',')?;
564 Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?))
565 })
566 .collect(),
567 capture_after: std::env::var("VIDYA_CAPTURE_AT")
568 .ok()
569 .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok()))
570 .map(Duration::from_millis),
571 started: Instant::now(),
Bring vidya in cfd3e36 nandi 19d ago572 },
573 stack: Stack::default(),
574 frame_budget: DEFAULT_FRAME_BUDGET,
575 frame_started: Instant::now(),
576 dropped: 0,
577 font_generation: 0,
578 };
579
580 // Pump until the platform resumes us and the window exists.
581 let deadline = Instant::now() + OPEN_TIMEOUT;
582 while app.handler.gl.is_none() && app.handler.error.is_none() {
583 if Instant::now() > deadline {
584 return Err("timed out waiting for a window".to_owned());
585 }
586 app.pump(Duration::from_millis(10));
587 }
588 match app.handler.error.take() {
589 Some(e) => Err(e),
590 None => Ok(app),
591 }
592 }
593
594 fn pump(&mut self, timeout: Duration) {
595 self.event_loop
596 .pump_app_events(Some(timeout), &mut self.handler);
597 }
598
599 pub fn should_close(&mut self) -> bool {
600 self.pump(Duration::ZERO);
601 self.handler.should_close
602 }
603
604 pub fn theme(&self) -> &Theme {
605 &self.handler.theme
606 }
607
608 /// The innermost open node plus the live theme.
609 ///
610 /// Returned together because widget calls need both, and they live in
611 /// different fields — splitting the borrow here keeps the call sites plain.
Tell a caller how big the window is 42dabb0 nandi 19d ago612 /// The window's size in points, as egui last saw it.
613 ///
614 /// Points rather than pixels: a caller sizing something against this is
615 /// laying out, and layout is in the same units the widgets are. The
616 /// `width`/`height` the window was opened with are no answer — they are
617 /// what was asked for once, and say nothing about a window since dragged
618 /// wider.
619 ///
620 /// egui keeps the last screen rect on its context, so this answers between
621 /// frames as well as during one. Zero before the first frame, which is the
622 /// honest answer to asking how big a window is before it has been painted.
623 pub fn screen_size(&self) -> (f32, f32) {
624 let rect = self.handler.egui_ctx.screen_rect();
625 if rect.width().is_finite() && rect.height().is_finite() {
626 (rect.width().max(0.0), rect.height().max(0.0))
627 } else {
628 (0.0, 0.0)
629 }
630 }
631
Bring vidya in cfd3e36 nandi 19d ago632 pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> {
633 let theme = &self.handler.theme;
634 self.stack.top().map(|ui| (ui, theme))
635 }
636
637 pub fn set_mode(&mut self, mode: vidya_core::Mode) {
638 self.handler.theme = match mode {
639 vidya_core::Mode::Dark => Theme::dark(),
640 vidya_core::Mode::Light => Theme::light(),
641 };
642 vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme);
643 }
644
Let the window be renamed after it opens 5adddc7 nandi 19d ago645 /// Rename the open window.
646 ///
647 /// Onto the handler as well as the window: on Android the window is taken
648 /// away and rebuilt whenever the activity leaves the foreground, and
649 /// `Gl::create` names the new one from `self.title`. A title set once and
650 /// only on the window would be the launch title again after a resume.
651 pub fn set_title(&mut self, title: &str) {
652 self.handler.title = title.to_owned();
653 if let Some(gl) = self.handler.gl.as_ref() {
654 gl.window.set_title(title);
655 }
656 }
657
Bring vidya in cfd3e36 nandi 19d ago658 pub fn set_target_fps(&mut self, fps: i32) {
659 self.frame_budget = match fps {
660 f if f > 0 => Duration::from_secs_f64(1.0 / f as f64),
661 _ => DEFAULT_FRAME_BUDGET,
662 };
663 }
664
665 /// Install a UI font as the highest-priority proportional family.
666 ///
667 /// The symbol fallback installed by [`vidya_core::apply`] stays in place,
668 /// so punctuation and block art keep rendering.
669 pub fn load_font(&mut self, path: &str) -> bool {
670 let Ok(bytes) = std::fs::read(path) else {
671 return false;
672 };
673 self.font_generation += 1;
674 // Unique name per load: egui skips a name it already has.
675 let name = format!("vidya-ui-{}", self.font_generation);
676 self.handler
677 .egui_ctx
678 .add_font(egui::epaint::text::FontInsert::new(
679 &name,
680 egui::FontData::from_owned(bytes),
681 vec![egui::epaint::text::InsertFontFamily {
682 family: egui::FontFamily::Proportional,
683 priority: egui::epaint::text::FontPriority::Highest,
684 }],
685 ));
686 true
687 }
688
689 /// Drain pending input and open an egui pass with a root [`egui::Ui`].
690 pub fn begin_frame(&mut self) {
691 self.pump(Duration::ZERO);
692 self.frame_started = Instant::now();
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago693 // Per-frame, and cleared here rather than after a present: a frame
694 // dropped because nobody was compositing never reaches one.
695 self.handler.slot_waited = false;
Bring vidya in cfd3e36 nandi 19d ago696
697 if self.handler.gl.is_none() || self.stack.is_active() {
698 // No window, or the caller skipped `vidya_end_frame`.
699 return;
700 }
701 let Some(gl) = self.handler.gl.as_mut() else {
702 return;
703 };
704
705 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 ago706 // The size that input reports the window to be, kept for
707 // `resized_mid_frame` to compare the window against once the caller
708 // has finished walking its tree.
709 let size = gl.window.inner_size();
710 let dims = [size.width.max(1), size.height.max(1)];
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago711 // A resize the compositor never sent, for testing a layout that only
712 // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT`
713 // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag
714 // of the window's edge would.
715 //
716 // Asked for here, after the input for this frame has been taken, so it
717 // lands where a drag actually lands it: in the middle of the walk,
718 // with the pass already measured against the old size. Requesting it
719 // from `present` instead would only ever resize between frames, which
720 // is the one case that was never a problem.
721 let building = self.handler.frames + 1;
722 for &(at, w, h) in &self.handler.resize_at {
723 if building == at {
724 let _ = gl
725 .window
726 .request_inner_size(winit::dpi::LogicalSize::new(w, h));
727 }
728 }
729
Bring vidya in cfd3e36 nandi 19d ago730 let ctx = &self.handler.egui_ctx;
731 ctx.begin_pass(input);
732 self.stack.push_root(ctx);
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago733 self.handler.frame_dims = dims;
734 }
735
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago736 /// Wait for the compositor to want a frame, dispatching events while we
737 /// wait — never inside `swap_buffers`, which would park the thread that
738 /// owes the compositor its replies and stall the session. This wait is
739 /// also what paces the caller's loop to the display.
740 ///
741 /// Callable before the pass is closed as well as from `end_frame`, and the
742 /// second call is free. That is the point: the resizes a drag produces
743 /// arrive *in here*, so a caller that wants to know whether its layout is
744 /// still the right size has to wait first and ask afterwards. Asking
745 /// before the wait sees a window that has not been told to move yet.
746 pub fn await_present_slot(&mut self) {
747 let deadline = Instant::now() + PRESENT_TIMEOUT;
748 while !self.handler.may_present && Instant::now() < deadline {
749 self.pump(Duration::from_millis(2));
750 }
751 self.handler.slot_waited = true;
752 }
753
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago754 /// Whether the window is no longer the size the last pass was measured
755 /// for. Unlike [`Self::resized_mid_frame`] it asks nothing of the open
756 /// pass, so it also answers between frames.
757 fn window_outgrew_frame(&self) -> bool {
758 let Some(gl) = self.handler.gl.as_ref() else {
759 return false;
760 };
761 let size = gl.window.inner_size();
762 [size.width.max(1), size.height.max(1)] != self.handler.frame_dims
763 }
764
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago765 /// Whether the window changed size after this frame started laying out.
766 ///
767 /// True means the pass now open measured a window that no longer exists,
768 /// and presenting it would paint a smaller layout into a larger buffer.
769 /// The caller answers by discarding the pass and walking the tree again;
770 /// the tree is retained and holds no sizes of its own, so a second walk is
771 /// simply the same tree laid out against the window as it now is.
772 pub fn resized_mid_frame(&mut self) -> bool {
773 if !self.stack.is_active() {
774 return false;
775 }
776 self.pump(Duration::ZERO);
777 let Some(gl) = self.handler.gl.as_ref() else {
778 return false;
779 };
780 let size = gl.window.inner_size();
Take the resize scaffolding back out 2ab40bf nandi 19d ago781 [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 ago782 }
783
784 /// Close the open pass and throw its output away, presenting nothing.
785 ///
786 /// egui has to be told a pass ended whether or not anybody wants what it
787 /// produced — leaving one open would make the next `begin_pass` panic —
788 /// so this ends it and drops the result on the floor.
789 pub fn discard_frame(&mut self) {
790 if !self.stack.is_active() {
791 return;
792 }
793 self.stack.unwind();
794 let _ = self.handler.egui_ctx.end_pass();
Bring vidya in cfd3e36 nandi 19d ago795 }
796
797 /// Close the pass, then hand the frame to the event loop to present.
798 pub fn end_frame(&mut self) {
799 if !self.stack.is_active() {
800 return;
801 }
802 // Close anything the caller left open (a missing `vidya_card_end`).
803 self.stack.unwind();
804 if self.handler.gl.is_none() {
805 return;
806 }
807
808 let egui::FullOutput {
809 platform_output,
810 textures_delta,
811 shapes,
812 pixels_per_point,
813 ..
814 } = self.handler.egui_ctx.end_pass();
815 let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point);
816 // Gamma, not linear: `Painter::clear` hands these straight to
817 // `glClearColor` against an sRGB framebuffer, so a `Rgba::from`
818 // conversion here would be applied twice and the window would clear to
819 // near-black instead of the palette's charcoal.
820 let clear = self
821 .handler
822 .theme
823 .palette
824 .window_bg
825 .to_normalized_gamma_f32();
826
827 if let Some(gl) = self.handler.gl.as_mut() {
828 gl.winit_state
829 .handle_platform_output(&gl.window, platform_output);
830 }
831
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago832 if !self.handler.slot_waited {
833 self.await_present_slot();
Bring vidya in cfd3e36 nandi 19d ago834 }
835
836 if self.handler.may_present {
837 self.handler.may_present = false;
838 self.handler.present(PaintJob {
839 primitives,
840 textures_delta,
841 pixels_per_point,
842 clear,
843 });
844 } else {
845 // Nobody is compositing this window (minimized, another workspace).
846 // Drop the frame rather than force a present that would block.
847 self.dropped += 1;
848 if self.dropped == 1 {
849 eprintln!("vidya: window is not being composited; dropping frames");
850 }
851 }
852
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago853 // Floor for platforms that do not throttle presents at all — spent
854 // pumping rather than asleep, and abandoned the moment the window
855 // changes size.
856 //
857 // A drag's next size lands in exactly this window. Sleeping through it
858 // means the resize is not even *seen* until the sleep ends, and the
859 // frame after that paints a layout the window outgrew a whole budget
860 // ago — the edge running ahead of the picture. Waiting on the event
861 // loop instead makes the same idle time responsive: the resize wakes
862 // it, and the next frame is laid out against the size the window
863 // already is.
864 while let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {
865 if left.is_zero() {
866 break;
867 }
868 self.pump(left.min(Duration::from_millis(2)));
869 if self.window_outgrew_frame() {
870 break;
871 }
Bring vidya in cfd3e36 nandi 19d ago872 }
873 }
874}
875
876impl Drop for App {
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago877 /// Order matters, and the field order would get it exactly backwards.
878 ///
879 /// `event_loop` is declared first, so it is dropped first — taking the
880 /// display connection with it while `Gl` still holds what was built on
881 /// top. Under Wayland that is fatal rather than untidy: egui-winit's
882 /// clipboard is a smithay-clipboard thread holding its own proxies, and
883 /// destroying them against a connection that has already gone aborts the
884 /// process inside libwayland. So the window's half comes down here, in
885 /// full, before anything else is allowed to.
Bring vidya in cfd3e36 nandi 19d ago886 fn drop(&mut self) {
887 self.stack.unwind();
888 if let Some(gl) = self.handler.gl.as_mut() {
889 gl.painter.destroy();
890 }
Spend the frame's idle time watching for the resize, not asleep a9f3673 nandi 17d ago891 drop(self.handler.gl.take());
Bring vidya in cfd3e36 nandi 19d ago892 }
893}
894
895/// Write the painted framebuffer to a binary PPM.
896///
897/// A rendering backend is otherwise unverifiable where the compositor refuses
898/// screenshots, and in CI where there is nobody to look. Off unless
899/// `VIDYA_CAPTURE` names a path.
900fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) {
901 use glow::HasContext as _;
902 use std::io::Write as _;
903
904 let [w, h] = dims;
905 let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
906 unsafe {
907 // Drain the pipeline: the pixels need not exist yet.
908 gl.painter.gl().finish();
909 gl.painter.gl().read_pixels(
910 0,
911 0,
912 w as i32,
913 h as i32,
914 glow::RGBA,
915 glow::UNSIGNED_BYTE,
916 glow::PixelPackData::Slice(Some(&mut rgba)),
917 );
918 }
919
920 let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32);
921 out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
922 // GL origin is bottom-left; PPM is top-down.
923 for row in (0..h as usize).rev() {
924 let start = row * w as usize * 4;
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago925 for px in rgba[start..start + w as usize * 4].as_chunks::<4>().0 {
Bring vidya in cfd3e36 nandi 19d ago926 out.extend_from_slice(&px[..3]);
927 }
928 }
929
930 match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) {
931 Ok(()) => eprintln!("vidya: wrote {path}"),
932 Err(e) => eprintln!("vidya: capture failed: {e}"),
933 }
934}