Skip to main content

brep_app/
desktop_loop.rs

1//! The desktop event loop.
2//!
3//! `eframe::run_native` runs the app only under winit's `RedrawRequested`.
4//! On Wayland winit withholds that event while the surface's frame callback
5//! is outstanding (winit 0.30 `wayland/event_loop/mod.rs`, which skips a window
6//! whose callback is `Requested`, with no timeout), and a compositor sends no
7//! callbacks to a window it is not showing (another workspace, a hidden
8//! scratchpad, a covered window under some compositors). An automation command
9//! queued then waited until the user focused the window: the 2026-10-31
10//! desktop report's 29 s initial read. eframe's fallback for invisible windows
11//! does not apply, because Wayland reports neither visibility nor minimisation
12//! (`is_visible`/`is_minimized` return `None`). And once a repaint is
13//! outstanding with no pass run, egui stops calling its repaint callback, so a
14//! later submit did not even wake the loop.
15//!
16//! `run` owns the winit loop instead (`eframe::create_native`) and wraps
17//! eframe's handler in `DesktopLoop`. The automation queue's waker sends the
18//! loop its own event (never forwarded to eframe), and after every batch of
19//! events the loop runs `BrepApp::service_automation` on this, the app's
20//! thread, whenever the queue's head can be dispatched without a frame. Frames
21//! still drain the queue exactly as before; this path draws nothing.
22//!
23//! A command that needs more than app state (input egui must apply, a layout
24//! read, a capture) does not wait for a frame either: the loop runs an egui
25//! pass itself, on demand (`run_pass`). It is the same pass eframe runs
26//! (`raw_input_hook`, then `logic` and `ui` inside `Context::run_ui`), on the
27//! window's last known size and clock, and it is never presented: a capture
28//! asked for in it is drawn into an offscreen texture (`crate::offscreen`).
29//! Everything else the pass hands back goes where eframe would have put it:
30//! its textures to the shared renderer, its viewport and platform commands
31//! back onto the context for eframe's next real frame, and a capture's
32//! `Event::Screenshot` into the next pass's input, as eframe delivers one.
33//!
34//! The loop also keeps an undrawn window idle. eframe arms
35//! `ControlFlow::Poll` whenever it requests a redraw (`check_redraw_requests`)
36//! and disarms it only in the pass that redraw runs, so a redraw the
37//! compositor never delivers leaves the loop polling, a core spinning while
38//! idle. winit wakes the loop itself when a requested redraw is delivered, so
39//! after one polled iteration without a `RedrawRequested`, the loop waits.
40use crate::app::BrepApp;
41use eframe::UserEvent;
42use std::cell::RefCell;
43use std::rc::Rc;
44use std::sync::{Arc, Mutex};
45use std::time::{Duration, Instant};
46use winit::application::ApplicationHandler;
47use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
48
49/// Wakes the loop thread (the automation queue's waker).
50pub type Waker = Arc<dyn Fn() + Send + Sync>;
51
52/// The pass number the wake event carries. It is never a real pass, and the
53/// event is never forwarded to eframe, so egui's repaint bookkeeping cannot
54/// see or swallow it.
55const WAKE: u64 = u64::MAX;
56
57/// While work a frame-free dispatch started is still in flight (a background
58/// history run, a store write) and no frame comes, it is serviced at the frame
59/// cadence: the rate a drawn window pumps it. Background runs report
60/// completion only by being polled (`Documents::pump_all`). This paces
61/// completion of accepted work only; dispatch itself is event-driven.
62const SERVICE_PACE: Duration = Duration::from_millis(16);
63
64/// The app, shared on the loop thread between eframe (through [`Shell`]) and
65/// the loop's service step. The two never run at once: winit delivers events
66/// to one handler, sequentially, on this thread.
67#[derive(Clone, Default)]
68pub struct Slot(Rc<RefCell<Option<Installed>>>);
69
70#[derive(Clone)]
71struct Installed {
72    app: Rc<RefCell<BrepApp>>,
73    ctx: egui::Context,
74    /// The window's renderer (shared with eframe's painter): an on-demand
75    /// pass uploads its textures there and draws its captures with it.
76    render: Option<eframe::egui_wgpu::RenderState>,
77    pass: Rc<RefCell<PassInput>>,
78}
79
80/// What an on-demand pass needs to stand in for eframe's input: the clock
81/// eframe's passes run on, the window's size and scale, and the events a
82/// capture delivers to the pass after it.
83struct PassInput {
84    /// `(egui time, when)` of the last real pass, to continue its clock.
85    clock: Option<(f64, Instant)>,
86    /// When the app was installed: the clock's origin before any real pass.
87    started: Instant,
88    /// The window's inner size in physical pixels, from `Resized`.
89    size: Option<winit::dpi::PhysicalSize<u32>>,
90    /// The window's scale factor, from `ScaleFactorChanged`.
91    scale: Option<f64>,
92    /// Events for the next pass, real or on demand (`Event::Screenshot`).
93    events: Vec<egui::Event>,
94}
95
96impl PassInput {
97    fn new() -> Self {
98        PassInput { clock: None, started: Instant::now(), size: None, scale: None, events: Vec::new() }
99    }
100
101    fn now(&self) -> f64 {
102        match self.clock {
103            Some((time, at)) => time + at.elapsed().as_secs_f64(),
104            None => self.started.elapsed().as_secs_f64(),
105        }
106    }
107}
108
109impl Slot {
110    /// Install the app from the eframe creation closure; returns eframe's
111    /// handle to it.
112    pub fn install(&self, app: BrepApp, ctx: &egui::Context, render: Option<eframe::egui_wgpu::RenderState>) -> Shell {
113        let app = Rc::new(RefCell::new(app));
114        let pass = Rc::new(RefCell::new(PassInput::new()));
115        *self.0.borrow_mut() = Some(Installed { app: app.clone(), ctx: ctx.clone(), render, pass: pass.clone() });
116        Shell(app, pass)
117    }
118
119    fn get(&self) -> Option<Installed> {
120        self.0.borrow().clone()
121    }
122}
123
124/// eframe's view of the shared app: every [`eframe::App`] method forwards.
125pub struct Shell(Rc<RefCell<BrepApp>>, Rc<RefCell<PassInput>>);
126
127impl eframe::App for Shell {
128    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
129        eframe::App::logic(&mut *self.0.borrow_mut(), ctx, frame)
130    }
131    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
132        eframe::App::ui(&mut *self.0.borrow_mut(), ui, frame)
133    }
134    fn save(&mut self, storage: &mut dyn eframe::Storage) {
135        eframe::App::save(&mut *self.0.borrow_mut(), storage)
136    }
137    fn on_exit(&mut self) {
138        eframe::App::on_exit(&mut *self.0.borrow_mut())
139    }
140    fn auto_save_interval(&self) -> Duration {
141        eframe::App::auto_save_interval(&*self.0.borrow())
142    }
143    fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
144        eframe::App::clear_color(&*self.0.borrow(), visuals)
145    }
146    fn persist_egui_memory(&self) -> bool {
147        eframe::App::persist_egui_memory(&*self.0.borrow())
148    }
149    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
150        {
151            // A real pass: an on-demand one continues its clock, and the
152            // events an on-demand capture left for "the next pass" are this one's.
153            let mut pass = self.1.borrow_mut();
154            if let Some(time) = raw_input.time {
155                pass.clock = Some((time, Instant::now()));
156            }
157            raw_input.events.append(&mut pass.events);
158        }
159        eframe::App::raw_input_hook(&mut *self.0.borrow_mut(), ctx, raw_input)
160    }
161}
162
163/// How many passes the loop has run on demand (never presented).
164static ON_DEMAND_PASSES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
165
166/// How many passes the loop has run on demand in this process.
167pub fn on_demand_passes() -> u64 {
168    ON_DEMAND_PASSES.load(std::sync::atomic::Ordering::SeqCst)
169}
170
171/// On-demand passes run at most at the frame cadence. The egui clock is wall
172/// time in them as in a drawn frame, so what counts time in frames (a click's
173/// timing, a dwell, `wait_frames`) sees what a drawn window would; and the
174/// loop goes back to the window's own events between two of them.
175const PASS_PACE: Duration = SERVICE_PACE;
176
177/// The surface an on-demand pass lays out on before the window has reported
178/// any size (in points; winit's default window size, which eframe keeps).
179const FALLBACK_SIZE: egui::Vec2 = egui::vec2(800.0, 600.0);
180
181/// Run one egui pass of the app now, as eframe's `run_ui_and_paint` would,
182/// but without presenting it. See the module doc.
183#[cfg(feature = "automation")]
184fn run_pass(app: &mut BrepApp, installed: &Installed) {
185    let ctx = &installed.ctx;
186    let raw = {
187        let mut pass = installed.pass.borrow_mut();
188        let mut raw = ctx.input(|i| i.raw.clone());
189        // The last pass's input stands for the window (its size, scale,
190        // focus, texture limit); its events are spent.
191        raw.events = std::mem::take(&mut pass.events);
192        raw.hovered_files.clear();
193        raw.dropped_files.clear();
194        raw.time = Some(pass.now());
195        let root = raw.viewports.entry(egui::ViewportId::ROOT).or_default();
196        root.events.clear();
197        if let Some(scale) = pass.scale {
198            root.native_pixels_per_point = Some(scale as f32);
199        }
200        // egui-winit's rule: points = physical pixels / (native scale x zoom).
201        let pixels_per_point = root.native_pixels_per_point.unwrap_or(1.0) * ctx.zoom_factor();
202        if let Some(size) = pass.size.filter(|s| s.width > 0 && s.height > 0) {
203            raw.screen_rect = Some(egui::Rect::from_min_size(
204                egui::Pos2::ZERO,
205                egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
206            ));
207        }
208        if raw.screen_rect.is_none() {
209            // No pass and no `Resized` yet: the size eframe opened the window
210            // at, rather than egui's unbounded default.
211            raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, FALLBACK_SIZE));
212        }
213        if raw.max_texture_side.is_none() {
214            raw.max_texture_side = installed.render.as_ref().map(|r| r.device.limits().max_texture_dimension_2d as usize);
215        }
216        raw
217    };
218    // Counted as it begins: the pass answers commands from inside it.
219    ON_DEMAND_PASSES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
220    let render = installed.render.as_ref();
221    let pass = crate::on_demand::run(app, ctx, render, raw);
222    // A capture is drawn here, waiting for the GPU (nothing is presented),
223    // and lands in the next pass's input as eframe delivers one.
224    if !pass.captures.is_empty() {
225        let (size_px, clear) = crate::on_demand::capture_target(app, ctx, pass.pixels_per_point);
226        let image = match render {
227            Some(render) => crate::offscreen::capture(render, ctx, pass.shapes, pass.pixels_per_point, size_px, clear),
228            None => Err("offscreen capture: the window has no wgpu render state".to_string()),
229        };
230        match image {
231            Ok(image) => installed.pass.borrow_mut().events.extend(crate::on_demand::screenshot_events(pass.captures, image)),
232            Err(error) => {
233                log::error!("{error}");
234                app.automation().fail_captures(&error);
235            }
236        }
237    }
238    if let Some(render) = render {
239        crate::offscreen::apply_texture_frees(render, &pass.textures_delta);
240    }
241}
242
243/// A waker that sends the loop its wake event. Safe from any thread; a closed
244/// loop ignores it.
245pub fn waker(proxy: EventLoopProxy<UserEvent>) -> Waker {
246    let proxy = Mutex::new(proxy);
247    Arc::new(move || {
248        let event = UserEvent::RequestRepaint { viewport_id: egui::ViewportId::ROOT, when: Instant::now(), cumulative_pass_nr: WAKE };
249        let _ = proxy.lock().unwrap_or_else(|p| p.into_inner()).send_event(event);
250    })
251}
252
253fn is_wake(event: &UserEvent) -> bool {
254    matches!(event, UserEvent::RequestRepaint { cumulative_pass_nr: WAKE, .. })
255}
256
257/// eframe's winit handler plus the frame-free service step.
258pub struct DesktopLoop<H> {
259    inner: H,
260    slot: Slot,
261    /// Work the last service step started is still in flight.
262    busy: bool,
263    /// The `WaitUntil` deadline the service step armed to pace that work.
264    paced: Option<Instant>,
265    /// The last iteration ended polling (eframe requested a redraw).
266    polled: bool,
267    /// A `RedrawRequested` arrived since the last `about_to_wait`.
268    redrawn: bool,
269    /// When the loop last ran a pass on demand ([`PASS_PACE`]).
270    last_pass: Option<Instant>,
271}
272
273impl<H: ApplicationHandler<UserEvent>> DesktopLoop<H> {
274    pub fn new(inner: H, slot: Slot) -> Self {
275        DesktopLoop { inner, slot, busy: false, paced: None, polled: false, redrawn: false, last_pass: None }
276    }
277
278    fn with_pass(&self, f: impl FnOnce(&mut PassInput)) {
279        if let Some(installed) = self.slot.get() {
280            f(&mut installed.pass.borrow_mut());
281        }
282    }
283
284    /// End eframe's poll for a redraw that did not come within one polled
285    /// iteration (a hidden Wayland window gets none). Stock behaviour while
286    /// redraws are delivered: each one disarms the poll in its own pass.
287    fn unstick_poll(&mut self, event_loop: &ActiveEventLoop) {
288        if event_loop.control_flow() == ControlFlow::Poll {
289            if self.polled && !self.redrawn {
290                event_loop.set_control_flow(ControlFlow::Wait);
291                self.polled = false;
292            } else {
293                self.polled = true;
294            }
295        } else {
296            self.polled = false;
297        }
298        self.redrawn = false;
299    }
300
301    /// One frame-free service step, when the queue's head needs no frame or
302    /// work it started is still in flight.
303    fn service(&mut self, event_loop: &ActiveEventLoop) {
304        #[cfg(feature = "automation")]
305        self.step();
306        #[cfg(not(feature = "automation"))]
307        let _ = &self.slot;
308        // The deadline this step armed last time, if the loop still holds it
309        // (eframe replaces it with its own when it schedules a repaint).
310        let ours = self.paced.take().filter(|at| event_loop.control_flow() == ControlFlow::WaitUntil(*at));
311        if self.busy {
312            let next = Instant::now() + SERVICE_PACE;
313            match event_loop.control_flow() {
314                ControlFlow::Poll => {}
315                // eframe's own earlier wake stands; ours is renewed.
316                ControlFlow::WaitUntil(at) if at <= next && ours.is_none() => {}
317                _ => {
318                    event_loop.set_control_flow(ControlFlow::WaitUntil(next));
319                    self.paced = Some(next);
320                }
321            }
322        } else if ours.is_some() {
323            // Work done: a deadline left in the past would wake the loop at
324            // once, every iteration.
325            event_loop.set_control_flow(ControlFlow::Wait);
326        }
327    }
328
329    /// Dispatch what needs no pass, and run the pass the queue's head needs
330    /// ([`PASS_PACE`] after the last one; until then the step stays busy, so
331    /// the loop comes back for it).
332    #[cfg(feature = "automation")]
333    fn step(&mut self) {
334        let Some(installed) = self.slot.get() else { return };
335        // Only eframe holds this borrow, and never across a loop callback.
336        let Ok(mut app) = installed.app.try_borrow_mut() else { return };
337        let queue = app.automation().clone();
338        let owed = loop {
339            if self.busy || queue.has_frameless_work() {
340                self.busy = app.service_automation(&installed.ctx);
341            }
342            let events = !installed.pass.borrow().events.is_empty();
343            // A measured spin advances one step per pass.
344            let owed = queue.needs_pass() || events || app.spinning();
345            if !owed || self.last_pass.is_some_and(|at| at.elapsed() < PASS_PACE) {
346                break owed;
347            }
348            run_pass(&mut app, &installed);
349            self.last_pass = Some(Instant::now());
350        };
351        self.busy |= owed;
352    }
353}
354
355impl<H: ApplicationHandler<UserEvent>> ApplicationHandler<UserEvent> for DesktopLoop<H> {
356    fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: winit::event::StartCause) {
357        self.inner.new_events(event_loop, cause);
358    }
359
360    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
361        self.inner.resumed(event_loop);
362    }
363
364    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
365        // The wake is ours alone; `about_to_wait`, which always follows the
366        // batch it arrived in, services the queue.
367        if !is_wake(&event) {
368            self.inner.user_event(event_loop, event);
369        }
370    }
371
372    fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: winit::window::WindowId, event: winit::event::WindowEvent) {
373        self.redrawn |= matches!(event, winit::event::WindowEvent::RedrawRequested);
374        // The window's size and scale, for an on-demand pass (eframe keeps
375        // its own copy where the loop cannot read it).
376        let resized = match &event {
377            winit::event::WindowEvent::Resized(size) => {
378                self.with_pass(|pass| pass.size = Some(*size));
379                true
380            }
381            winit::event::WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
382                let scale = *scale_factor;
383                self.with_pass(|pass| pass.scale = Some(scale));
384                true
385            }
386            _ => false,
387        };
388        self.inner.window_event(event_loop, window_id, event);
389        #[cfg(feature = "automation")]
390        if resized {
391            if let Some(installed) = self.slot.get() {
392                if let Ok(app) = installed.app.try_borrow() {
393                    app.automation().mark_layout_dirty();
394                }
395            }
396        }
397        #[cfg(not(feature = "automation"))]
398        let _ = resized;
399    }
400
401    fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: winit::event::DeviceId, event: winit::event::DeviceEvent) {
402        self.inner.device_event(event_loop, device_id, event);
403    }
404
405    /// After every batch of events, including the batch a wake arrived in
406    /// and the one in which eframe created the app: a command queued before
407    /// the app existed, or before the waker was installed, is found here.
408    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
409        self.inner.about_to_wait(event_loop);
410        self.unstick_poll(event_loop);
411        if !event_loop.exiting() {
412            self.service(event_loop);
413        }
414    }
415
416    fn suspended(&mut self, event_loop: &ActiveEventLoop) {
417        self.inner.suspended(event_loop);
418    }
419
420    fn exiting(&mut self, event_loop: &ActiveEventLoop) {
421        self.inner.exiting(event_loop);
422    }
423
424    fn memory_warning(&mut self, event_loop: &ActiveEventLoop) {
425        self.inner.memory_warning(event_loop);
426    }
427}
428
429/// What the creation closure is given: eframe's context and the waker to
430/// install on the app's automation queue before anything can submit to it.
431pub type Create<'a> = Box<dyn 'a + FnOnce(&eframe::CreationContext<'_>, Waker) -> Result<BrepApp, String>>;
432
433/// Run the app on a new event loop.
434pub fn run(app_name: &str, options: eframe::NativeOptions, create: Create<'_>) -> Result<(), String> {
435    let event_loop = EventLoop::<UserEvent>::with_user_event().build().map_err(|e| format!("event loop: {e}"))?;
436    run_on(event_loop, app_name, options, create, |eframe| eframe)
437}
438
439/// Run the app on `event_loop`. `wrap` sits between [`DesktopLoop`] and
440/// eframe's handler: the identity in production; a test interposes there to
441/// play a compositor that withholds frames.
442pub fn run_on<'a, H: ApplicationHandler<UserEvent>>(
443    event_loop: EventLoop<UserEvent>,
444    app_name: &str,
445    options: eframe::NativeOptions,
446    create: Create<'a>,
447    wrap: impl FnOnce(eframe::EframeWinitApplication<'a>) -> H,
448) -> Result<(), String> {
449    let slot = Slot::default();
450    let failure: Rc<RefCell<Option<String>>> = Rc::default();
451    let waker = waker(event_loop.create_proxy());
452    let (installer, failed) = (slot.clone(), failure.clone());
453    // A history run (or any runner reply) landing wakes the loop to collect
454    // it, rather than waiting for the service step's next timed check.
455    brep_render::runner::set_reply_waker(Some(waker.clone()));
456    let creator: eframe::AppCreator<'a> = Box::new(move |cc| match create(cc, waker) {
457        Ok(app) => Ok(Box::new(installer.install(app, &cc.egui_ctx, cc.wgpu_render_state.clone())) as Box<dyn eframe::App>),
458        Err(error) => {
459            *failed.borrow_mut() = Some(error.clone());
460            Err(error.into())
461        }
462    });
463    let eframe = eframe::create_native(app_name, options, creator, &event_loop);
464    let mut handler = DesktopLoop::new(wrap(eframe), slot);
465    event_loop.run_app(&mut handler).map_err(|e| format!("event loop: {e}"))?;
466    let failed = failure.borrow_mut().take();
467    match failed {
468        Some(error) => Err(error),
469        None => Ok(()),
470    }
471}