Skip to main content

pfx_editor_shell/
window.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::{Duration, Instant, SystemTime};
5
6use egui::{Context, ViewportId};
7use egui_wgpu::{RendererOptions, ScreenDescriptor};
8use egui_winit::winit::application::ApplicationHandler;
9use egui_winit::winit::dpi::LogicalSize;
10use egui_winit::winit::event::WindowEvent;
11use egui_winit::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
12use egui_winit::winit::window::{Window, WindowId};
13use pfx_gpu::{Gpu, WindowTarget, wgpu};
14
15use crate::paint::Painter;
16use crate::pgpu::{self, Pgpu};
17use crate::{App, Error, Theme, Watch, block_on, context};
18
19const SLOW: Duration = Duration::from_millis(250);
20const TALLY: u32 = 240;
21const SIZE: [f64; 2] = [1586.0, 992.0];
22
23pub trait Pulse {
24    fn frame(&self, work: Duration);
25    fn idle(&self);
26    fn label(&self, _label: &str) {}
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30enum Signal {
31    Files(u64, Vec<PathBuf>),
32    App,
33    Pgpu(Pgpu),
34}
35
36struct Live {
37    window: Arc<Window>,
38    gpu: Gpu,
39    target: WindowTarget,
40    state: egui_winit::State,
41    painter: Painter,
42}
43
44#[derive(Default)]
45struct Tally {
46    frames: u32,
47    work: Duration,
48    longest: Duration,
49    gpu_ms: f64,
50    gpu_frames: u32,
51    noted: u64,
52}
53
54impl Tally {
55    fn add(&mut self, name: &str, work: Duration, gpu_ms: Option<f64>, drops: u64) -> Vec<String> {
56        let mut lines = Vec::new();
57        if drops > self.noted {
58            lines.push(format!(
59                "{name}: {drops} frame timings dropped so far; the GPU ran more than two frames behind"
60            ));
61            self.noted = drops.saturating_mul(2);
62        }
63        self.frames += 1;
64        self.work += work;
65        self.longest = self.longest.max(work);
66        if let Some(ms) = gpu_ms {
67            self.gpu_ms += ms;
68            self.gpu_frames += 1;
69        }
70        if self.frames == TALLY {
71            let gpu = if self.gpu_frames > 0 {
72                format!("{:.2} ms", self.gpu_ms / f64::from(self.gpu_frames))
73            } else {
74                "unmeasured".to_string()
75            };
76            lines.push(format!(
77                "{name}: {} frames, the UI thread {:.2} ms a frame (longest {:.2} ms), the viewport on the GPU {gpu}",
78                self.frames,
79                self.work.as_secs_f64() * 1000.0 / f64::from(self.frames),
80                self.longest.as_secs_f64() * 1000.0,
81            ));
82            *self = Tally {
83                noted: self.noted,
84                ..Tally::default()
85            };
86        }
87        lines
88    }
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92struct Stamp {
93    modified: Option<SystemTime>,
94    len: u64,
95}
96
97fn stamp(path: &Path) -> Option<Stamp> {
98    let meta = std::fs::metadata(path).ok()?;
99    Some(Stamp {
100        modified: meta.modified().ok(),
101        len: meta.len(),
102    })
103}
104
105fn moved(files: &[PathBuf], last: &[Option<Stamp>], now: &[Option<Stamp>]) -> Vec<PathBuf> {
106    files
107        .iter()
108        .zip(last.iter().zip(now))
109        .filter(|(_, (then, now))| then != now)
110        .map(|(file, _)| file.clone())
111        .collect()
112}
113
114fn watch_files(
115    watch: Watch,
116    generation: u64,
117    stop: Arc<AtomicBool>,
118    proxy: EventLoopProxy<Signal>,
119) {
120    std::thread::spawn(move || {
121        let stamps = || {
122            watch
123                .files
124                .iter()
125                .map(|file| stamp(file))
126                .collect::<Vec<_>>()
127        };
128        let mut last = stamps();
129        while !stop.load(Ordering::Relaxed) {
130            std::thread::sleep(watch.every);
131            if stop.load(Ordering::Relaxed) {
132                return;
133            }
134            let now = stamps();
135            if now != last {
136                let changed = moved(&watch.files, &last, &now);
137                last = now;
138                if proxy
139                    .send_event(Signal::Files(generation, changed))
140                    .is_err()
141                {
142                    return;
143                }
144            }
145        }
146    });
147}
148
149fn title(name: &str, label: Option<&str>) -> String {
150    match label {
151        Some(label) => format!("{name} · {label}"),
152        None => name.to_string(),
153    }
154}
155
156fn retitle(window: &Window, pulse: Option<&dyn Pulse>, app: &impl App) {
157    let label = app.label();
158    window.set_title(&title(app.name(), label.as_deref()));
159    if let (Some(pulse), Some(label)) = (pulse, label) {
160        pulse.label(&label);
161    }
162}
163
164struct Watcher {
165    stop: Option<Arc<AtomicBool>>,
166    watch: Watch,
167    generation: u64,
168}
169
170impl Watcher {
171    fn stop(&mut self) {
172        if let Some(stop) = self.stop.take() {
173            stop.store(true, Ordering::Relaxed);
174        }
175    }
176
177    fn follow(&mut self, watch: Watch, proxy: &EventLoopProxy<Signal>) {
178        if self.stop.is_some() && watch == self.watch {
179            return;
180        }
181        self.stop();
182        self.generation += 1;
183        self.watch = watch.clone();
184        if watch.files.is_empty() {
185            return;
186        }
187        let flag = Arc::new(AtomicBool::new(false));
188        self.stop = Some(Arc::clone(&flag));
189        watch_files(watch, self.generation, flag, proxy.clone());
190    }
191}
192
193struct Host<A: App> {
194    app: A,
195    ctx: Context,
196    live: Option<Live>,
197    failure: Option<String>,
198    pulse: Option<Box<dyn Pulse>>,
199    proxy: EventLoopProxy<Signal>,
200    watching: bool,
201    watcher: Watcher,
202    pgpu: Pgpu,
203    drew: bool,
204    started: Instant,
205    ui_shown: bool,
206    view_shown: bool,
207    tally: Tally,
208}
209
210impl<A: App> Host<A> {
211    fn fail(&mut self, event_loop: &ActiveEventLoop, failure: String) {
212        self.failure = Some(failure);
213        event_loop.exit();
214    }
215
216    fn open(&mut self, event_loop: &ActiveEventLoop) -> Result<Live, String> {
217        let attributes = Window::default_attributes()
218            .with_title(title(self.app.name(), self.app.label().as_deref()))
219            .with_inner_size(LogicalSize::new(SIZE[0], SIZE[1]));
220        let window = Arc::new(
221            event_loop
222                .create_window(attributes)
223                .map_err(|error| error.to_string())?,
224        );
225        retitle(&window, self.pulse.as_deref(), &self.app);
226        let (gpu, target) = block_on(Gpu::window(window.clone()))?;
227        let side = gpu.device.limits().max_texture_dimension_2d as usize;
228        let state = egui_winit::State::new(
229            self.ctx.clone(),
230            ViewportId::ROOT,
231            event_loop,
232            Some(window.scale_factor() as f32),
233            window.theme(),
234            Some(side),
235        );
236        let painter = Painter::new(&gpu, target.config.format, RendererOptions::default());
237        window.request_redraw();
238        if !self.watching {
239            self.watching = true;
240            let proxy = self.proxy.clone();
241            pgpu::watch(self.app.name().to_string(), move |state| {
242                proxy.send_event(Signal::Pgpu(state)).is_ok()
243            });
244        }
245        if self.watcher.stop.is_none() {
246            self.watcher.follow(self.app.watched(), &self.proxy);
247        }
248        Ok(Live {
249            window,
250            gpu,
251            target,
252            state,
253            painter,
254        })
255    }
256
257    fn redraw(&mut self) -> Result<(), String> {
258        let Host {
259            app,
260            ctx,
261            live,
262            pulse,
263            proxy,
264            watcher,
265            drew,
266            started: opened,
267            ui_shown,
268            view_shown,
269            tally,
270            ..
271        } = self;
272        let Some(live) = live.as_mut() else {
273            return Ok(());
274        };
275        let started = Instant::now();
276        let native = live.window.scale_factor() as f32;
277        ctx.set_zoom_factor(native.round().max(1.0) / native);
278        let input = live.state.take_egui_input(&live.window);
279        let mut output = ctx.run_ui(input, |root| app.ui(root));
280        if app.opened() {
281            watcher.follow(app.watched(), proxy);
282            retitle(&live.window, pulse.as_deref(), app);
283        }
284        let mut more = false;
285        if app.viewport() {
286            more = app.frame(&live.gpu, live.painter.renderer(), output.pixels_per_point);
287        }
288        live.state
289            .handle_platform_output(&live.window, output.platform_output);
290        let primitives = ctx.tessellate(output.shapes, output.pixels_per_point);
291        let frame = match live.target.surface.get_current_texture() {
292            wgpu::CurrentSurfaceTexture::Success(frame)
293            | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
294            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
295                let size = live.window.inner_size();
296                live.target.resize(&live.gpu, size.width, size.height);
297                live.window.request_redraw();
298                return Ok(());
299            }
300            wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
301                live.window.request_redraw();
302                return Ok(());
303            }
304            wgpu::CurrentSurfaceTexture::Validation => {
305                return Err("surface validation failed".to_string());
306            }
307        };
308        let view = frame
309            .texture
310            .create_view(&wgpu::TextureViewDescriptor::default());
311        let screen = ScreenDescriptor {
312            size_in_pixels: [live.target.config.width, live.target.config.height],
313            pixels_per_point: output.pixels_per_point,
314        };
315        live.painter.paint(
316            &live.gpu,
317            &view,
318            &screen,
319            &output.textures_delta,
320            &primitives,
321            Theme::of(ctx).bg,
322        );
323        output.textures_delta.clear();
324        live.window.pre_present_notify();
325        live.gpu.queue.present(frame);
326        let work = started.elapsed();
327        let name = app.name().to_string();
328        if work >= SLOW {
329            eprintln!("{name}: a frame took {} ms", work.as_millis());
330        }
331        let timing = app.timing();
332        for line in tally.add(&name, work, timing.gpu_ms, timing.drops) {
333            eprintln!("{line}");
334        }
335        if let Some(pulse) = pulse.as_ref() {
336            pulse.frame(work);
337        }
338        *drew = true;
339        if !*ui_shown {
340            *ui_shown = true;
341            eprintln!(
342                "{name}: the window's first frame at {} ms",
343                opened.elapsed().as_millis()
344            );
345        }
346        if !*view_shown && timing.drawn {
347            *view_shown = true;
348            eprintln!(
349                "{name}: the viewport's first frame at {} ms",
350                opened.elapsed().as_millis()
351            );
352        }
353        let again = output
354            .viewport_output
355            .get(&ViewportId::ROOT)
356            .is_some_and(|viewport| viewport.repaint_delay.is_zero());
357        if again || more {
358            live.window.request_redraw();
359        }
360        Ok(())
361    }
362}
363
364impl<A: App> ApplicationHandler<Signal> for Host<A> {
365    fn user_event(&mut self, _: &ActiveEventLoop, signal: Signal) {
366        let mut redraw = false;
367        match signal {
368            Signal::Files(generation, files) => {
369                if generation == self.watcher.generation {
370                    self.app.changed(&files);
371                    self.watcher.follow(self.app.watched(), &self.proxy);
372                    redraw = true;
373                }
374            }
375            Signal::App => redraw = true,
376            Signal::Pgpu(state) => {
377                if state != self.pgpu {
378                    self.pgpu = state;
379                    self.app.pgpu(state);
380                    redraw = true;
381                }
382                if !self.drew
383                    && let Some(pulse) = self.pulse.as_ref()
384                {
385                    pulse.idle();
386                }
387                self.drew = false;
388            }
389        }
390        if redraw && let Some(live) = self.live.as_ref() {
391            live.window.request_redraw();
392        }
393    }
394
395    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
396        if self.live.is_some() {
397            return;
398        }
399        match self.open(event_loop) {
400            Ok(live) => self.live = Some(live),
401            Err(failure) => self.fail(event_loop, failure),
402        }
403    }
404
405    fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
406        let Some(live) = self.live.as_mut() else {
407            return;
408        };
409        match &event {
410            WindowEvent::CloseRequested => {
411                event_loop.exit();
412                return;
413            }
414            WindowEvent::RedrawRequested => {
415                if let Err(failure) = self.redraw() {
416                    self.fail(event_loop, failure);
417                }
418                return;
419            }
420            WindowEvent::Resized(size) => {
421                live.target.resize(&live.gpu, size.width, size.height);
422                live.window.request_redraw();
423            }
424            _ => {}
425        }
426        let response = live.state.on_window_event(&live.window, &event);
427        if response.repaint {
428            live.window.request_redraw();
429        }
430    }
431
432    fn exiting(&mut self, _: &ActiveEventLoop) {
433        self.watcher.stop();
434        self.live = None;
435    }
436}
437
438pub fn window(mut app: impl App, pulse: Option<Box<dyn Pulse>>) -> Result<(), Error> {
439    pfx_gpu::refuse_window("the editor window").map_err(Error)?;
440    let event_loop = EventLoop::<Signal>::with_user_event()
441        .build()
442        .map_err(|error| Error(error.to_string()))?;
443    event_loop.set_control_flow(ControlFlow::Wait);
444    let ctx = context(&app.theme());
445    let proxy = event_loop.create_proxy();
446    let started = Instant::now();
447    let wake = proxy.clone();
448    app.waker(Arc::new(move || {
449        let _ = wake.send_event(Signal::App);
450    }));
451    let mut host = Host {
452        app,
453        ctx,
454        live: None,
455        failure: None,
456        pulse,
457        proxy,
458        watching: false,
459        watcher: Watcher {
460            stop: None,
461            watch: Watch::default(),
462            generation: 0,
463        },
464        pgpu: Pgpu::default(),
465        drew: false,
466        started,
467        ui_shown: false,
468        view_shown: false,
469        tally: Tally::default(),
470    };
471    event_loop
472        .run_app(&mut host)
473        .map_err(|error| Error(error.to_string()))?;
474    match host.failure {
475        Some(failure) => Err(Error(failure)),
476        None => Ok(()),
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    struct Quiet;
485
486    impl Pulse for Quiet {
487        fn frame(&self, _: Duration) {}
488        fn idle(&self) {}
489    }
490
491    #[test]
492    fn the_title_names_the_label_and_a_pulse_may_ignore_it() {
493        assert_eq!(title("editor", None), "editor");
494        assert_eq!(title("editor", Some("desk.toml")), "editor · desk.toml");
495        Quiet.label("desk.toml");
496    }
497
498    #[test]
499    fn dropped_timings_are_noted_as_they_climb_and_not_every_frame() {
500        let mut tally = Tally::default();
501        let ms = Duration::from_millis(2);
502        assert!(tally.add("editor", ms, Some(4.0), 0).is_empty());
503        let noted: Vec<usize> = [1, 1, 2, 3, 3, 9, 10, 30]
504            .into_iter()
505            .map(|drops| tally.add("editor", ms, None, drops).len())
506            .collect();
507        assert_eq!(noted, [1, 0, 0, 1, 0, 1, 0, 1]);
508        assert!(
509            tally.add("editor", ms, None, 61)[0]
510                .starts_with("editor: 61 frame timings dropped so far")
511        );
512    }
513
514    #[test]
515    fn every_240_frames_log_the_ui_threads_time_and_the_gpus() {
516        let mut tally = Tally::default();
517        let mut lines = Vec::new();
518        for frame in 0..TALLY * 2 {
519            let work = Duration::from_micros(if frame == 7 { 9_000 } else { 1_000 });
520            lines.extend(tally.add("editor", work, (frame % 2 == 0).then_some(5.0), 0));
521        }
522        assert_eq!(
523            lines,
524            [
525                "editor: 240 frames, the UI thread 1.03 ms a frame (longest 9.00 ms), the viewport on the GPU 5.00 ms",
526                "editor: 240 frames, the UI thread 1.00 ms a frame (longest 1.00 ms), the viewport on the GPU 5.00 ms",
527            ]
528        );
529    }
530
531    #[test]
532    fn only_the_files_whose_stamps_moved_are_named() {
533        let files = [
534            PathBuf::from("a.toml"),
535            PathBuf::from("b.toml"),
536            PathBuf::from("c.toml"),
537        ];
538        let at = |len| {
539            Some(Stamp {
540                modified: None,
541                len,
542            })
543        };
544        let last = [at(1), None, at(3)];
545        let now = [at(1), at(2), at(4)];
546        assert_eq!(moved(&files, &last, &now), files[1..].to_vec());
547        assert!(moved(&files, &last, &last).is_empty());
548    }
549
550    #[test]
551    fn a_stamp_reads_a_file_and_none_for_a_missing_one() {
552        let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp/shell-tests/stamp");
553        std::fs::create_dir_all(&dir).unwrap();
554        let file = dir.join("watched.toml");
555        std::fs::write(&file, "a = 1\n").unwrap();
556        assert_eq!(stamp(&file).map(|stamp| stamp.len), Some(6));
557        assert_eq!(stamp(&dir.join("missing.toml")), None);
558    }
559}