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}