1use crate::clipboard::Clipboard;
2use egui::{OrderedViewportIdMap, Ui, ViewportCommand, ViewportId, ViewportOutput};
3use egui_wgpu::ScreenDescriptor;
4use image::GenericImageView;
5use sdl3::VideoSubsystem;
6use sdl3::event::{Event, WindowEvent};
7use sdl3::pixels::PixelFormat;
8use sdl3::video::{Window, WindowBuildError};
9use std::cmp::Ordering;
10use std::iter;
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, SystemTime};
13use thiserror::Error;
14
15pub struct FrameContext<'frame> {
16    pub device: &'frame wgpu::Device,
17    pub queue: &'frame wgpu::Queue,
18    pub renderer: &'frame mut egui_wgpu::Renderer,
19}
20
21#[derive(Debug, Clone)]
22pub struct FrameOptions {
23    pub window_width: u32,
24    pub window_height: u32,
25    pub resizable: bool,
26    pub text_input: bool,
27    pub egui_theme: egui::ThemePreference,
28    pub install_egui_image_loaders: bool,
29    pub icon: Option<image::DynamicImage>,
30    pub wgpu_backends: wgpu::Backends,
31    pub wgpu_power_preference: wgpu::PowerPreference,
32    pub wgpu_present_mode: wgpu::PresentMode,
33}
34
35impl Default for FrameOptions {
36    fn default() -> Self {
37        Self {
38            window_width: 800,
39            window_height: 600,
40            resizable: true,
41            text_input: true,
42            egui_theme: egui::ThemePreference::System,
43            install_egui_image_loaders: false,
44            icon: None,
45            wgpu_backends: wgpu::Backends::PRIMARY,
46            wgpu_power_preference: wgpu::PowerPreference::None,
47            wgpu_present_mode: wgpu::PresentMode::AutoNoVsync,
48        }
49    }
50}
51
52#[derive(Debug, Error)]
53pub enum FrameCreateError {
54    #[error("Error creating SDL3 window: {0}")]
55    SdlWindow(#[from] WindowBuildError),
56    #[error("Error setting window icon: {0}")]
57    WindowIcon(#[source] sdl3::Error),
58    #[error("Error obtaining window/display handle: {0}")]
59    WindowHandle(#[from] raw_window_handle::HandleError),
60    #[error("Error creating wgpu surface: {0}")]
61    WgpuCreateSurface(#[from] wgpu::CreateSurfaceError),
62    #[error("Error obtaining wgpu adapter: {0}")]
63    WgpuRequestAdapter(#[from] wgpu::RequestAdapterError),
64    #[error("Error obtaining wgpu device and queue: {0}")]
65    WgpuRequestDevice(#[from] wgpu::RequestDeviceError),
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum FrameRunEffect {
70    None,
71    Closed,
72}
73
74#[derive(Debug, Error)]
75pub enum FrameRunError {
76    #[error("wgpu surface was lost or failed validation")]
77    WgpuSurfaceLost,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81struct NextRepaint {
82    delay: Duration,
83    pass_number: u64,
84}
85
86impl NextRepaint {
87    #[must_use]
88    fn min(self, other: Self) -> Self {
89        // Prefer higher pass numbers first, then lower delay
90        let ordering =
91            self.pass_number.cmp(&other.pass_number).reverse().then(self.delay.cmp(&other.delay));
92
93        match ordering {
94            Ordering::Greater => other,
95            Ordering::Less | Ordering::Equal => self,
96        }
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum ViewportEffect {
102    Close,
103}
104
105pub struct Frame {
106    surface: wgpu::Surface<'static>,
107    surface_config: wgpu::SurfaceConfiguration,
108    device: wgpu::Device,
109    queue: wgpu::Queue,
110    platform: crate::Platform,
111    egui_renderer: egui_wgpu::Renderer,
112    start_time: SystemTime,
113    last_repaint: SystemTime,
114    next_repaint: Arc<Mutex<NextRepaint>>,
115    paint_count: u64,
116    window_shown: bool,
117    closed: bool,
118    clipboard: Clipboard,
119    // SAFETY: The window must be dropped after the surface and the clipboard
120    window: Window,
121}
122
123impl Frame {
124    #[allow(clippy::missing_errors_doc)]
125    #[allow(clippy::missing_panics_doc)]
126    pub fn new(
127        window_title: &str,
128        video: &VideoSubsystem,
129        options: FrameOptions,
130    ) -> Result<Self, FrameCreateError> {
131        let display_scale_factor = try_get_primary_display_scale(video).unwrap_or(1.0);
132        let window_width = (options.window_width as f32 * display_scale_factor).round() as u32;
133        let window_height = (options.window_height as f32 * display_scale_factor).round() as u32;
134
135        let mut window_builder = video.window(window_title, window_width, window_height);
136
137        window_builder.hidden();
138        window_builder.metal_view();
139        window_builder.high_pixel_density();
140
141        if options.resizable {
142            window_builder.resizable();
143        }
144
145        let mut window = window_builder.build()?;
146
147        if let Some(icon) = &options.icon {
148            set_window_icon(&mut window, icon).map_err(FrameCreateError::WindowIcon)?;
149        }
150
151        let (width, height) = window.size_in_pixels();
152
153        if options.text_input {
154            video.text_input().start(&window);
155        }
156
157        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
158            backends: options.wgpu_backends,
159            ..wgpu::InstanceDescriptor::new_without_display_handle()
160        });
161
162        // SAFETY: The surface must not outlive the window
163        let surface = unsafe {
164            instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::from_display_and_window(
165                &window, &window,
166            )?)
167        }?;
168
169        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
170            power_preference: options.wgpu_power_preference,
171            force_fallback_adapter: false,
172            compatible_surface: Some(&surface),
173        }))?;
174
175        let (device, queue) =
176            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))?;
177
178        let surface_capabilities = surface.get_capabilities(&adapter);
179
180        // Exclude formats with non-empty required features because some platforms claim to support
181        // RGBA16 surface formats when they actually do not
182        let supported_formats: Vec<_> = surface_capabilities
183            .formats
184            .iter()
185            .copied()
186            .filter(|&format| format.required_features().is_empty())
187            .collect();
188
189        // egui prefers non-sRGB-aware surface formats
190        let surface_format = supported_formats
191            .iter()
192            .copied()
193            .find(|&format| !format.is_srgb())
194            .or(supported_formats.first().copied())
195            .unwrap_or(surface_capabilities.formats[0]);
196
197        log::info!(
198            "Creating frame for window '{window_title}' with surface format {surface_format:?}"
199        );
200
201        let surface_config = wgpu::SurfaceConfiguration {
202            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
203            format: surface_format,
204            width,
205            height,
206            present_mode: options.wgpu_present_mode,
207            desired_maximum_frame_latency: 2,
208            alpha_mode: wgpu::CompositeAlphaMode::Auto,
209            view_formats: vec![],
210        };
211        surface.configure(&device, &surface_config);
212
213        let platform = crate::Platform::new(&window);
214        platform.context().set_theme(options.egui_theme);
215
216        if options.install_egui_image_loaders {
217            egui_extras::install_image_loaders(platform.context());
218        }
219
220        let start_time = SystemTime::now();
221        let next_repaint =
222            Arc::new(Mutex::new(NextRepaint { delay: Duration::ZERO, pass_number: 0 }));
223
224        platform.context().set_request_repaint_callback({
225            let next_repaint = Arc::clone(&next_repaint);
226            move |info| {
227                let mut next_repaint = next_repaint.lock().unwrap();
228                *next_repaint = next_repaint.min(NextRepaint {
229                    delay: info.delay,
230                    pass_number: info.current_cumulative_pass_nr,
231                });
232            }
233        });
234
235        let egui_renderer = egui_wgpu::Renderer::new(
236            &device,
237            surface_format,
238            egui_wgpu::RendererOptions::default(),
239        );
240
241        // SAFETY: The clipboard must not outlive the window
242        let clipboard = unsafe { Clipboard::new(&window) };
243
244        Ok(Self {
245            surface,
246            surface_config,
247            device,
248            queue,
249            platform,
250            egui_renderer,
251            start_time,
252            last_repaint: start_time,
253            next_repaint,
254            paint_count: 0,
255            window_shown: false,
256            closed: false,
257            clipboard,
258            window,
259        })
260    }
261
262    #[allow(clippy::missing_errors_doc)]
263    #[allow(clippy::missing_panics_doc)]
264    pub fn run(
265        &mut self,
266        render_fn: &mut dyn FnMut(&mut Ui, FrameContext<'_>),
267    ) -> Result<FrameRunEffect, FrameRunError> {
268        // Painting at least 3 times before waiting seems to avoid a black screen at window open on some platforms
269        // Maybe some sort of triple buffering in the graphics driver
270        const MIN_PAINTS_BEFORE_WAIT: u64 = 3;
271
272        if self.closed {
273            return Ok(FrameRunEffect::Closed);
274        }
275
276        let now = SystemTime::now();
277        let since_last_repaint = now.duration_since(self.last_repaint).unwrap_or_default();
278
279        {
280            let mut next_repaint = self.next_repaint.lock().unwrap();
281            let needs_repaint = since_last_repaint >= next_repaint.delay;
282
283            if self.paint_count >= MIN_PAINTS_BEFORE_WAIT && !needs_repaint {
284                return Ok(FrameRunEffect::None);
285            }
286
287            self.paint_count += 1;
288
289            if next_repaint.pass_number < self.platform.context().cumulative_pass_nr() {
290                next_repaint.delay = Duration::MAX;
291            }
292        }
293
294        let egui_input = self
295            .platform
296            .take_raw_input(now.duration_since(self.start_time).unwrap_or_default().as_secs_f64());
297
298        let full_output = self.platform.context().run_ui(egui_input, |ui| {
299            let frame_ctx = FrameContext {
300                device: &self.device,
301                queue: &self.queue,
302                renderer: &mut self.egui_renderer,
303            };
304            render_fn(ui, frame_ctx);
305        });
306        self.platform.handle_egui_output(&full_output.platform_output, &mut self.clipboard);
307
308        if let Some(effect) = self.handle_viewport_output(&full_output.viewport_output) {
309            return match effect {
310                ViewportEffect::Close => {
311                    self.closed = true;
312                    Ok(FrameRunEffect::Closed)
313                }
314            };
315        }
316
317        let mut suboptimal_surface = false;
318        let output = match self.surface.get_current_texture() {
319            wgpu::CurrentSurfaceTexture::Success(output) => output,
320            wgpu::CurrentSurfaceTexture::Suboptimal(output) => {
321                suboptimal_surface = true;
322                output
323            }
324            wgpu::CurrentSurfaceTexture::Outdated => {
325                log::warn!("Skipping frame because wgpu surface is outdated");
326                self.surface.configure(&self.device, &self.surface_config);
327                return Ok(FrameRunEffect::None);
328            }
329            wgpu::CurrentSurfaceTexture::Timeout => {
330                log::warn!("Skipping frame because wgpu surface timed out");
331                self.surface.configure(&self.device, &self.surface_config);
332                return Ok(FrameRunEffect::None);
333            }
334            wgpu::CurrentSurfaceTexture::Occluded => {
335                log::debug!("Skipping frame because wgpu surface is occluded");
336                return Ok(FrameRunEffect::None);
337            }
338            wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Validation => {
339                return Err(FrameRunError::WgpuSurfaceLost);
340            }
341        };
342        let output_view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
343
344        let paint_jobs =
345            self.platform.context().tessellate(full_output.shapes, full_output.pixels_per_point);
346
347        let screen_descriptor = ScreenDescriptor {
348            size_in_pixels: [self.surface_config.width, self.surface_config.height],
349            pixels_per_point: full_output.pixels_per_point,
350        };
351
352        for (id, image_delta) in &full_output.textures_delta.set {
353            self.egui_renderer.update_texture(&self.device, &self.queue, *id, image_delta);
354        }
355
356        let mut encoder =
357            self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
358
359        self.egui_renderer.update_buffers(
360            &self.device,
361            &self.queue,
362            &mut encoder,
363            &paint_jobs,
364            &screen_descriptor,
365        );
366
367        {
368            let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
369                label: "egui_render_pass".into(),
370                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
371                    view: &output_view,
372                    depth_slice: None,
373                    resolve_target: None,
374                    ops: wgpu::Operations {
375                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
376                        store: wgpu::StoreOp::Store,
377                    },
378                })],
379                ..wgpu::RenderPassDescriptor::default()
380            });
381
382            // egui-wgpu requires a RenderPass with static lifetime
383            let mut render_pass = render_pass.forget_lifetime();
384
385            self.egui_renderer.render(&mut render_pass, &paint_jobs, &screen_descriptor);
386        }
387
388        self.queue.submit(iter::once(encoder.finish()));
389        output.present();
390
391        for id in &full_output.textures_delta.free {
392            self.egui_renderer.free_texture(id);
393        }
394
395        if suboptimal_surface {
396            self.surface.configure(&self.device, &self.surface_config);
397        }
398
399        self.last_repaint = now;
400
401        // Waiting to show the window until after the first paint avoids a briefly visible black screen
402        if !self.window_shown {
403            self.window.show();
404            self.window.raise();
405            self.window_shown = true;
406        }
407
408        Ok(FrameRunEffect::None)
409    }
410
411    pub fn update_egui_theme(&mut self, theme_preference: egui::ThemePreference) {
412        self.platform.context().set_theme(theme_preference);
413    }
414
415    #[allow(clippy::missing_panics_doc)]
416    pub fn handle_sdl_event(&mut self, event: &Event) {
417        if self.closed {
418            return;
419        }
420
421        match event {
422            Event::Quit { .. } => {
423                self.closed = true;
424                return;
425            }
426            Event::Window { window_id, win_event, .. } if *window_id == self.window.id() => {
427                match win_event {
428                    WindowEvent::CloseRequested => {
429                        self.closed = true;
430                        return;
431                    }
432                    WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
433                        let (width, height) = self.window.size_in_pixels();
434                        self.surface_config.width = width;
435                        self.surface_config.height = height;
436                        self.surface.configure(&self.device, &self.surface_config);
437
438                        self.platform.context().request_repaint();
439                    }
440                    _ => {}
441                }
442            }
443            _ => {}
444        }
445
446        self.platform.handle_event(event, &mut self.clipboard);
447
448        if self.platform.has_pending_input_event() {
449            self.platform.context().request_repaint();
450        }
451    }
452
453    fn handle_viewport_output(
454        &mut self,
455        viewport_output: &OrderedViewportIdMap<ViewportOutput>,
456    ) -> Option<ViewportEffect> {
457        let Some(output) = viewport_output.get(&ViewportId::ROOT) else {
458            return Some(ViewportEffect::Close);
459        };
460
461        for command in &output.commands {
462            match command {
463                ViewportCommand::Close => {
464                    return Some(ViewportEffect::Close);
465                }
466                ViewportCommand::Focus => {
467                    self.window.raise();
468                }
469                ViewportCommand::RequestCut => {
470                    self.platform.request_cut();
471                }
472                ViewportCommand::RequestCopy => {
473                    self.platform.request_copy();
474                }
475                ViewportCommand::RequestPaste => {
476                    self.platform.request_paste(self.clipboard.load());
477                }
478                _ => {
479                    log::warn!("unhandled egui viewport command: {command:?}");
480                }
481            }
482        }
483
484        None
485    }
486
487    pub fn window_id(&self) -> u32 {
488        self.window.id()
489    }
490
491    pub fn egui_ctx(&self) -> &egui::Context {
492        self.platform.context()
493    }
494}
495
496fn try_get_primary_display_scale(video: &VideoSubsystem) -> Option<f32> {
497    video.get_primary_display().ok().and_then(|display| display.get_content_scale().ok())
498}
499
500fn set_window_icon(window: &mut Window, icon: &image::DynamicImage) -> Result<(), sdl3::Error> {
501    let mut pixels = vec![0_u8; (4 * icon.width() * icon.height()) as usize];
502
503    for (x, y, image::Rgba([r, g, b, a])) in icon.pixels() {
504        let pixels_idx = (4 * (y * icon.width() + x)) as usize;
505
506        pixels[pixels_idx] = b;
507        pixels[pixels_idx + 1] = g;
508        pixels[pixels_idx + 2] = r;
509        pixels[pixels_idx + 3] = a;
510    }
511
512    let surface = sdl3::surface::Surface::from_data(
513        &mut pixels,
514        icon.width(),
515        icon.height(),
516        4 * icon.width(),
517        PixelFormat::BGRA32,
518    )?;
519    window.set_icon(&surface);
520
521    Ok(())
522}