From 13a756d6a5cfd40baf2116a6cac0bca9a84d0036 Mon Sep 17 00:00:00 2001 From: Philpax Date: Sat, 29 Nov 2025 01:04:53 +0100 Subject: [PATCH] feat(ui): add multi-viewport support, convert games window to viewport --- src/ui/internal.rs | 718 ++++++++++++++++++++++------- src/ui/mod.rs | 139 ++++-- src/ui/views/main/windows/games.rs | 35 +- 3 files changed, 693 insertions(+), 199 deletions(-) diff --git a/src/ui/internal.rs b/src/ui/internal.rs index 38782b7..40a8f30 100644 --- a/src/ui/internal.rs +++ b/src/ui/internal.rs @@ -1,23 +1,76 @@ /// based on https://github.com/kaphula/winit-egui-wgpu-template/blob/master/src/egui_tools.rs +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap}, + rc::Rc, + sync::Arc, +}; + use egui_wgpu::ScreenDescriptor; use egui_wgpu::wgpu; use egui_wgpu::wgpu::SurfaceError; use egui_winit::State as EguiWinitState; -use winit::window::Window; +use winit::{ + event_loop::ActiveEventLoop, + window::{Window, WindowId}, +}; + +/// Per-viewport state containing window-specific resources +pub struct ViewportState { + pub window: Arc, + pub surface: wgpu::Surface<'static>, + pub surface_config: wgpu::SurfaceConfiguration, + pub egui_state: EguiWinitState, +} + +/// Shared rendering state that can be accessed from the immediate viewport callback +pub struct SharedWgpuState { + // Shared resources + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub egui_renderer: egui_wgpu::Renderer, + pub texture_format: wgpu::TextureFormat, + pub adapter: wgpu::Adapter, + pub instance: wgpu::Instance, + + // Per-viewport resources + pub viewports: HashMap, + pub window_to_viewport: HashMap, + + // Event loop reference for creating windows in immediate viewports + pub event_loop: Option<*const ActiveEventLoop>, +} + +// Safety: We only access event_loop from the main thread during rendering +unsafe impl Send for SharedWgpuState {} +unsafe impl Sync for SharedWgpuState {} + +impl SharedWgpuState { + fn resize_viewport(&mut self, viewport_id: egui::ViewportId, width: u32, height: u32) { + if let Some(viewport) = self.viewports.get_mut(&viewport_id) + && width > 0 + && height > 0 + { + viewport.surface_config.width = width; + viewport.surface_config.height = height; + viewport + .surface + .configure(&self.device, &viewport.surface_config); + } + } +} pub struct WgpuState { - device: wgpu::Device, - queue: wgpu::Queue, - surface_config: wgpu::SurfaceConfiguration, - surface: wgpu::Surface<'static>, - egui_renderer: EguiRenderer, + pub shared: Rc>, + pub egui_ctx: egui::Context, } + impl WgpuState { /// based on https://github.com/kaphula/winit-egui-wgpu-template/blob/master/src/egui_tools.rs pub async fn new( - instance: &wgpu::Instance, - surface: wgpu::Surface<'static>, - window: &Window, + instance: wgpu::Instance, + initial_surface: wgpu::Surface<'static>, + window: Arc, width: u32, height: u32, ) -> Self { @@ -28,7 +81,7 @@ impl WgpuState { .request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_pref, force_fallback_adapter: false, - compatible_surface: Some(&surface), + compatible_surface: Some(&initial_surface), }) .await .expect("Failed to find an appropriate adapter"); @@ -47,9 +100,9 @@ impl WgpuState { tracing::debug!("WGPU device and queue created"); tracing::debug!("Configuring surface"); - let swapchain_capabilities = surface.get_capabilities(&adapter); + let swapchain_capabilities = initial_surface.get_capabilities(&adapter); let selected_format = wgpu::TextureFormat::Bgra8UnormSrgb; - let swapchain_format = swapchain_capabilities + let texture_format = *swapchain_capabilities .formats .iter() .find(|d| **d == selected_format) @@ -57,7 +110,7 @@ impl WgpuState { let surface_config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: *swapchain_format, + format: texture_format, width, height, // if u use AutoNoVsync instead it will fix tearing behaviour when resizing, but at cost of significantly higher CPU usage @@ -67,199 +120,554 @@ impl WgpuState { view_formats: vec![], }; - surface.configure(&device, &surface_config); + initial_surface.configure(&device, &surface_config); tracing::debug!("Surface configured"); + // Create shared egui context + let egui_ctx = egui::Context::default(); + + // Enable native viewports (instead of embedding them in the root window) + egui_ctx.set_embed_viewports(false); + + // Create egui_winit state for ROOT viewport + let egui_state = egui_winit::State::new( + egui_ctx.clone(), + egui::ViewportId::ROOT, + &window, + Some(window.scale_factor() as f32), + None, + Some(2048), + ); + + // Create shared egui renderer tracing::debug!("Creating egui renderer"); - let egui_renderer = EguiRenderer::new(&device, surface_config.format, None, 1, window); + let egui_renderer = egui_wgpu::Renderer::new( + &device, + texture_format, + egui_wgpu::RendererOptions { + msaa_samples: 1, + depth_stencil_format: None, + ..Default::default() + }, + ); tracing::debug!("Egui renderer created"); - tracing::debug!("WgpuState::new() complete"); - Self { + // Create ROOT viewport state + let root_viewport = ViewportState { + window: window.clone(), + surface: initial_surface, + surface_config, + egui_state, + }; + + let mut viewports = HashMap::new(); + viewports.insert(egui::ViewportId::ROOT, root_viewport); + + let mut window_to_viewport = HashMap::new(); + window_to_viewport.insert(window.id(), egui::ViewportId::ROOT); + + let shared = Rc::new(RefCell::new(SharedWgpuState { device, queue, - surface, - surface_config, egui_renderer, + texture_format, + adapter, + instance, + viewports, + window_to_viewport, + event_loop: None, + })); + + // Set up immediate viewport renderer + let shared_for_callback = shared.clone(); + let ctx_for_callback = egui_ctx.clone(); + egui::Context::set_immediate_viewport_renderer(move |ctx, immediate_viewport| { + let mut shared = shared_for_callback.borrow_mut(); + render_immediate_viewport(&mut shared, &ctx_for_callback, ctx, immediate_viewport); + }); + + tracing::debug!("WgpuState::new() complete"); + Self { shared, egui_ctx } + } + + pub fn context(&self) -> &egui::Context { + &self.egui_ctx + } + + /// Get the viewport ID for a given window ID + pub fn get_viewport_id(&self, window_id: WindowId) -> Option { + self.shared + .borrow() + .window_to_viewport + .get(&window_id) + .copied() + } + + /// Get the root viewport's window + pub fn root_window(&self) -> Option> { + self.shared + .borrow() + .viewports + .get(&egui::ViewportId::ROOT) + .map(|v| v.window.clone()) + } + + /// Resize a viewport's surface + pub fn resize_viewport(&mut self, viewport_id: egui::ViewportId, width: u32, height: u32) { + self.shared + .borrow_mut() + .resize_viewport(viewport_id, width, height); + } + + /// Handle input for a specific viewport + pub fn handle_input( + &mut self, + viewport_id: egui::ViewportId, + event: &winit::event::WindowEvent, + ) -> egui_winit::EventResponse { + let mut shared = self.shared.borrow_mut(); + if let Some(viewport) = shared.viewports.get_mut(&viewport_id) { + viewport.egui_state.on_window_event(&viewport.window, event) + } else { + egui_winit::EventResponse { + consumed: false, + repaint: false, + } } } - pub fn resize_surface(&mut self, width: u32, height: u32) { - self.surface_config.width = width; - self.surface_config.height = height; - self.surface.configure(&self.device, &self.surface_config); + /// Set the event loop reference for window creation + pub fn set_event_loop(&mut self, event_loop: &ActiveEventLoop) { + self.shared.borrow_mut().event_loop = Some(event_loop as *const _); } - pub fn context(&self) -> &egui::Context { - self.egui_renderer.context() + /// Clear the event loop reference + pub fn clear_event_loop(&mut self) { + self.shared.borrow_mut().event_loop = None; } - pub fn renderer(&mut self) -> &mut EguiRenderer { - &mut self.egui_renderer + /// Render the root viewport + pub fn render(&mut self, ui: impl FnOnce(&egui::Context)) -> Option { + self.render_viewport(egui::ViewportId::ROOT, ui) } - pub fn render(&mut self, window: &Window, ui: impl FnOnce(&egui::Context)) { - let screen_descriptor = ScreenDescriptor { - size_in_pixels: [self.surface_config.width, self.surface_config.height], - pixels_per_point: window.scale_factor() as f32, + /// Render a specific viewport + pub fn render_viewport( + &mut self, + viewport_id: egui::ViewportId, + ui: impl FnOnce(&egui::Context), + ) -> Option { + // First phase: get input and prepare for rendering + let (raw_input, screen_descriptor, surface_texture, surface_view, encoder) = { + let mut shared = self.shared.borrow_mut(); + + // Create encoder first before borrowing viewport + let encoder = shared + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + + let viewport = shared.viewports.get_mut(&viewport_id)?; + + let screen_descriptor = ScreenDescriptor { + size_in_pixels: [ + viewport.surface_config.width, + viewport.surface_config.height, + ], + pixels_per_point: viewport.window.scale_factor() as f32, + }; + + let surface_texture = match viewport.surface.get_current_texture() { + Ok(t) => t, + Err(SurfaceError::Outdated) => return None, + Err(e) => { + tracing::error!("Failed to get surface texture: {:?}", e); + return None; + } + }; + + let surface_view = surface_texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let raw_input = viewport.egui_state.take_egui_input(&viewport.window); + + ( + raw_input, + screen_descriptor, + surface_texture, + surface_view, + encoder, + ) }; - let surface_texture = self.surface.get_current_texture(); + // Second phase: run UI (may trigger immediate viewport callbacks) + self.egui_ctx.begin_pass(raw_input); + ui(&self.egui_ctx); + let full_output = self.egui_ctx.end_pass(); - match surface_texture { - Err(SurfaceError::Outdated) => { - // Ignoring outdated to allow resizing and minimization - return; + // Third phase: finish rendering + render_egui_output( + &mut self.shared.borrow_mut(), + &self.egui_ctx, + viewport_id, + full_output.clone(), + encoder, + &surface_view, + &screen_descriptor, + ); + + surface_texture.present(); + + Some(full_output) + } + + /// Process viewport output to create/destroy/update viewports + pub fn process_viewport_output( + &mut self, + event_loop: &ActiveEventLoop, + viewport_output: &BTreeMap, + ) { + let mut shared = self.shared.borrow_mut(); + + // Collect viewport IDs that should exist + let active_viewport_ids: std::collections::HashSet<_> = + viewport_output.keys().copied().collect(); + + // Create new viewports + for (viewport_id, output) in viewport_output.iter() { + if *viewport_id == egui::ViewportId::ROOT { + // Process commands for ROOT but don't recreate it + if let Some(viewport) = shared.viewports.get(&egui::ViewportId::ROOT) { + process_viewport_commands(&viewport.window, &output.commands); + } + continue; } - Err(_) => { - surface_texture.expect("Failed to acquire next swap chain texture"); + + if !shared.viewports.contains_key(viewport_id) { + // Create new viewport + if let Some(window) = create_window_from_builder(event_loop, &output.builder) { + add_viewport(&mut shared, *viewport_id, window, &self.egui_ctx); + } + } else if let Some(viewport) = shared.viewports.get(viewport_id) { + // Process commands for existing viewport + process_viewport_commands(&viewport.window, &output.commands); + } + } + + // Remove viewports that are no longer active (except ROOT) + let viewports_to_remove: Vec<_> = shared + .viewports + .keys() + .filter(|id| **id != egui::ViewportId::ROOT && !active_viewport_ids.contains(*id)) + .copied() + .collect(); + + for viewport_id in viewports_to_remove { + if let Some(viewport) = shared.viewports.remove(&viewport_id) { + shared.window_to_viewport.remove(&viewport.window.id()); + tracing::debug!("Removed viewport {:?}", viewport_id); + } + } + } +} + +/// Helper to render egui output to a viewport +fn render_egui_output( + shared: &mut SharedWgpuState, + egui_ctx: &egui::Context, + viewport_id: egui::ViewportId, + full_output: egui::FullOutput, + mut encoder: wgpu::CommandEncoder, + surface_view: &wgpu::TextureView, + screen_descriptor: &ScreenDescriptor, +) { + // Handle platform output + if let Some(viewport) = shared.viewports.get_mut(&viewport_id) { + viewport + .egui_state + .handle_platform_output(&viewport.window, full_output.platform_output.clone()); + } + + // Tessellate and render + let tris = egui_ctx.tessellate(full_output.shapes.clone(), full_output.pixels_per_point); + + // Update textures - need to get device/queue refs before mutable borrow of renderer + let device = &shared.device; + let queue = &shared.queue; + + for (id, image_delta) in &full_output.textures_delta.set { + shared + .egui_renderer + .update_texture(device, queue, *id, image_delta); + } + + shared + .egui_renderer + .update_buffers(device, queue, &mut encoder, &tris, screen_descriptor); + + { + let rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("egui render pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: surface_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + shared + .egui_renderer + .render(&mut rpass.forget_lifetime(), &tris, screen_descriptor); + } + + for x in &full_output.textures_delta.free { + shared.egui_renderer.free_texture(x) + } + + shared.queue.submit(Some(encoder.finish())); + + if let Some(viewport) = shared.viewports.get(&viewport_id) { + viewport.window.pre_present_notify(); + } +} + +/// Render an immediate viewport - called from the egui callback +fn render_immediate_viewport( + shared: &mut SharedWgpuState, + egui_ctx: &egui::Context, + ctx: &egui::Context, + mut immediate_viewport: egui::ImmediateViewport, +) { + let viewport_id = immediate_viewport.ids.this; + + // Create viewport if it doesn't exist + if !shared.viewports.contains_key(&viewport_id) { + // Safety: event_loop is only set during render, and we're being called from render + let event_loop = match shared.event_loop { + Some(ptr) => unsafe { &*ptr }, + None => { + tracing::error!("No event loop available for immediate viewport creation"); return; } - Ok(_) => {} }; - let surface_texture = surface_texture.unwrap(); + if let Some(window) = create_window_from_builder(event_loop, &immediate_viewport.builder) { + add_viewport(shared, viewport_id, window, egui_ctx); + } else { + tracing::error!("Failed to create window for viewport {:?}", viewport_id); + return; + } + } + + // Create encoder first before borrowing viewport + let encoder = shared + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + + // Get the data we need from the viewport + let (screen_descriptor, surface_texture, surface_view, raw_input) = { + let viewport = match shared.viewports.get_mut(&viewport_id) { + Some(v) => v, + None => return, + }; + + let screen_descriptor = ScreenDescriptor { + size_in_pixels: [ + viewport.surface_config.width, + viewport.surface_config.height, + ], + pixels_per_point: viewport.window.scale_factor() as f32, + }; + + let surface_texture = match viewport.surface.get_current_texture() { + Ok(t) => t, + Err(SurfaceError::Outdated) => return, + Err(e) => { + tracing::error!( + "Failed to get surface texture for immediate viewport: {:?}", + e + ); + return; + } + }; let surface_view = surface_texture .texture .create_view(&wgpu::TextureViewDescriptor::default()); - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + let raw_input = viewport.egui_state.take_egui_input(&viewport.window); - { - self.egui_renderer.begin_frame(window); - - // Render the main UI - ui(self.context()); - - self.egui_renderer.end_frame_and_draw( - &self.device, - &self.queue, - &mut encoder, - window, - &surface_view, - screen_descriptor, - ); - } + (screen_descriptor, surface_texture, surface_view, raw_input) + }; - self.queue.submit(Some(encoder.finish())); + // Run the viewport's UI + ctx.begin_pass(raw_input); + (immediate_viewport.viewport_ui_cb)(ctx); + let full_output = ctx.end_pass(); - // I don't feel like this is doing anything, but according to the docs it's supposed to be useful - // eh. I'll just leave it here I guess... - window.pre_present_notify(); + // Render using the shared helper + render_egui_output( + shared, + ctx, + viewport_id, + full_output, + encoder, + &surface_view, + &screen_descriptor, + ); - surface_texture.present(); - } + surface_texture.present(); } -pub struct EguiRenderer { - egui_ctx: egui::Context, - egui_state: EguiWinitState, - renderer: egui_wgpu::Renderer, +fn add_viewport( + shared: &mut SharedWgpuState, + viewport_id: egui::ViewportId, + window: Arc, + egui_ctx: &egui::Context, +) { + let surface = shared + .instance + .create_surface(window.clone()) + .expect("Failed to create surface for viewport"); + + let swapchain_capabilities = surface.get_capabilities(&shared.adapter); + let size = window.inner_size(); + + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: shared.texture_format, + width: size.width.max(1), + height: size.height.max(1), + present_mode: wgpu::PresentMode::AutoVsync, + desired_maximum_frame_latency: 2, + alpha_mode: swapchain_capabilities.alpha_modes[0], + view_formats: vec![], + }; + + surface.configure(&shared.device, &surface_config); + + let egui_state = egui_winit::State::new( + egui_ctx.clone(), + viewport_id, + &window, + Some(window.scale_factor() as f32), + None, + Some(2048), + ); + + let viewport_state = ViewportState { + window: window.clone(), + surface, + surface_config, + egui_state, + }; + + shared.window_to_viewport.insert(window.id(), viewport_id); + shared.viewports.insert(viewport_id, viewport_state); + + tracing::debug!( + "Added viewport {:?} for window {:?}", + viewport_id, + window.id() + ); } -impl EguiRenderer { - pub fn new( - device: &wgpu::Device, - output_color_format: wgpu::TextureFormat, - output_depth_format: Option, - msaa_samples: u32, - window: &Window, - ) -> Self { - let egui_ctx = egui::Context::default(); - let egui_state = egui_winit::State::new( - egui_ctx.clone(), - egui::ViewportId::ROOT, - &window, - Some(window.scale_factor() as f32), - None, - Some(2048), - ); - - let renderer = egui_wgpu::Renderer::new( - device, - output_color_format, - egui_wgpu::RendererOptions { - msaa_samples, - depth_stencil_format: output_depth_format, - ..Default::default() - }, - ); +fn create_window_from_builder( + event_loop: &ActiveEventLoop, + builder: &egui::ViewportBuilder, +) -> Option> { + let mut window_attributes = Window::default_attributes(); - Self { - egui_ctx, - egui_state, - renderer, - } + if let Some(title) = &builder.title { + window_attributes = window_attributes.with_title(title.clone()); } - pub fn context(&self) -> &egui::Context { - &self.egui_ctx + if let Some(inner_size) = builder.inner_size { + window_attributes = window_attributes + .with_inner_size(winit::dpi::LogicalSize::new(inner_size.x, inner_size.y)); } - pub fn handle_input( - &mut self, - window: &Window, - event: &winit::event::WindowEvent, - ) -> egui_winit::EventResponse { - self.egui_state.on_window_event(window, event) + if let Some(min_size) = builder.min_inner_size { + window_attributes = window_attributes + .with_min_inner_size(winit::dpi::LogicalSize::new(min_size.x, min_size.y)); } - pub fn begin_frame(&mut self, window: &Window) { - let raw_input = self.egui_state.take_egui_input(window); - self.egui_ctx.begin_pass(raw_input); + if let Some(max_size) = builder.max_inner_size { + window_attributes = window_attributes + .with_max_inner_size(winit::dpi::LogicalSize::new(max_size.x, max_size.y)); } - pub fn end_frame_and_draw( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - window: &Window, - window_surface_view: &wgpu::TextureView, - screen_descriptor: ScreenDescriptor, - ) { - let full_output = self.egui_ctx.end_pass(); - - self.egui_state - .handle_platform_output(window, full_output.platform_output); - - let tris = self - .egui_ctx - .tessellate(full_output.shapes, full_output.pixels_per_point); - for (id, image_delta) in &full_output.textures_delta.set { - self.renderer - .update_texture(device, queue, *id, image_delta); - } + if let Some(resizable) = builder.resizable { + window_attributes = window_attributes.with_resizable(resizable); + } - self.renderer - .update_buffers(device, queue, encoder, &tris, &screen_descriptor); + if let Some(decorations) = builder.decorations { + window_attributes = window_attributes.with_decorations(decorations); + } - { - let rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("egui main render pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: window_surface_view, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - - self.renderer - .render(&mut rpass.forget_lifetime(), &tris, &screen_descriptor); + match event_loop.create_window(window_attributes) { + Ok(window) => Some(Arc::new(window)), + Err(e) => { + tracing::error!("Failed to create window: {:?}", e); + None } + } +} - for x in &full_output.textures_delta.free { - self.renderer.free_texture(x) +fn process_viewport_commands(window: &Window, commands: &[egui::ViewportCommand]) { + for cmd in commands { + match cmd { + egui::ViewportCommand::Title(title) => { + window.set_title(title); + } + egui::ViewportCommand::Visible(visible) => { + window.set_visible(*visible); + } + egui::ViewportCommand::InnerSize(size) => { + let _ = window.request_inner_size(winit::dpi::LogicalSize::new(size.x, size.y)); + } + egui::ViewportCommand::MinInnerSize(size) => { + window.set_min_inner_size(Some(winit::dpi::LogicalSize::new(size.x, size.y))); + } + egui::ViewportCommand::MaxInnerSize(size) => { + window.set_max_inner_size(Some(winit::dpi::LogicalSize::new(size.x, size.y))); + } + egui::ViewportCommand::Resizable(resizable) => { + window.set_resizable(*resizable); + } + egui::ViewportCommand::Decorations(decorations) => { + window.set_decorations(*decorations); + } + egui::ViewportCommand::Focus => { + window.focus_window(); + } + egui::ViewportCommand::RequestUserAttention(attention) => { + let winit_attention = match attention { + egui::UserAttentionType::Informational => { + Some(winit::window::UserAttentionType::Informational) + } + egui::UserAttentionType::Critical => { + Some(winit::window::UserAttentionType::Critical) + } + egui::UserAttentionType::Reset => None, + }; + window.request_user_attention(winit_attention); + } + egui::ViewportCommand::Minimized(minimized) => { + window.set_minimized(*minimized); + } + egui::ViewportCommand::Maximized(maximized) => { + window.set_maximized(*maximized); + } + // Close is handled by removing the viewport from the output + _ => {} } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9a01920..8baa581 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -87,9 +87,8 @@ pub fn start( } struct WinitApp { - instance: wgpu::Instance, + instance: Option, wgpu_state: Option, - window: Option>, main_app: views::App, last_repaint_requested: Instant, /// Receives commands from various tx in other threads to perform some UI update @@ -135,9 +134,8 @@ impl WinitApp { tracing::debug!("WinitApp::new() complete"); Ok(Self { - instance: wgpu_instance, + instance: Some(wgpu_instance), wgpu_state: None, - window: None, main_app, last_repaint_requested: Instant::now(), ui_update_rx, @@ -150,46 +148,71 @@ impl WinitApp { let window = Arc::new(window); let _ = window.request_inner_size(inner_size); - let surface = self - .instance + let instance = self.instance.take().expect("Instance already consumed"); + + let surface = instance .create_surface(window.clone()) .expect("Failed to create surface!"); let state = internal::WgpuState::new( - &self.instance, + instance, surface, - &window, + window, inner_size.width, inner_size.height, ) .await; - self.window.get_or_insert(window); self.wgpu_state.get_or_insert(state); } - fn handle_resized(&mut self, width: u32, height: u32) { + fn handle_resized(&mut self, viewport_id: egui::ViewportId, width: u32, height: u32) { if width > 0 && height > 0 && let Some(state) = self.wgpu_state.as_mut() { - state.resize_surface(width, height); + state.resize_viewport(viewport_id, width, height); } } - fn handle_redraw(&mut self) { - // Attempt to handle minimizing window - let Some(window) = self.window.as_ref() else { - return; - }; - if window.is_minimized().is_some_and(|v| v) { - return; - } + fn handle_redraw(&mut self, event_loop: &ActiveEventLoop, viewport_id: egui::ViewportId) { let Some(state) = self.wgpu_state.as_mut() else { return; }; - state.render(window, |ctx| self.main_app.render(ctx)); + // Check if this viewport's window is minimized + { + let shared = state.shared.borrow(); + if let Some(viewport) = shared.viewports.get(&viewport_id) + && viewport.window.is_minimized().is_some_and(|v| v) + { + return; + } + } + + // Set event loop for immediate viewport creation + state.set_event_loop(event_loop); + + // Render the viewport and get the full output + let full_output = state.render(|ctx| self.main_app.render(ctx)); + + // Clear event loop reference + state.clear_event_loop(); + + // Process viewport output to handle new/closed viewports + if let Some(output) = full_output { + state.process_viewport_output(event_loop, &output.viewport_output); + + // Request repaints for viewports that need it + let shared = state.shared.borrow(); + for (vp_id, vp_output) in output.viewport_output.iter() { + if vp_output.repaint_delay.is_zero() + && let Some(viewport) = shared.viewports.get(vp_id) + { + viewport.window.request_redraw(); + } + } + } } fn title(recording: bool) -> String { @@ -204,7 +227,7 @@ impl WinitApp { impl ApplicationHandler for WinitApp { fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.window.is_some() { + if self.wgpu_state.is_some() { return; } @@ -230,11 +253,12 @@ impl ApplicationHandler for WinitApp { futures::executor::block_on(self.set_window(window, inner_size)); // Initialize tray icon and egui context after window is created - let Some(ctx) = self.wgpu_state.as_ref().map(|state| state.context()) else { + let Some(state) = self.wgpu_state.as_ref() else { return; }; - if let Some(window) = self.window.clone() { + let ctx = state.context(); + if let Some(window) = state.root_window() { self.main_app.resumed(ctx, window.clone()); ctx.set_request_repaint_callback(move |_info| { // We just ignore the delay for now @@ -243,7 +267,31 @@ impl ApplicationHandler for WinitApp { } } - fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.wgpu_state.as_ref() else { + return; + }; + + // Look up which viewport this window belongs to + let Some(viewport_id) = state.get_viewport_id(window_id) else { + return; + }; + + if viewport_id == egui::ViewportId::ROOT { + self.handle_root_window_event(event_loop, event); + } else { + self.handle_child_window_event(viewport_id, event); + } + } +} + +impl WinitApp { + fn handle_root_window_event(&mut self, event_loop: &ActiveEventLoop, event: WindowEvent) { let Some(state) = self.wgpu_state.as_mut() else { return; }; @@ -252,17 +300,17 @@ impl ApplicationHandler for WinitApp { // that the UI is using the latest config. self.main_app.copy_in_app_config(); - // Let egui renderer process the event first - let response = state - .renderer() - .handle_input(self.window.as_ref().unwrap(), &event); + // Let egui handle the input event + let response = state.handle_input(egui::ViewportId::ROOT, &event); // We throttle this so we aren't unnecessarily repainting for what is otherwise a relatively // simple UI. 16ms ~= 60fps. if response.repaint && self.last_repaint_requested.elapsed() > Duration::from_millis(16) { - if let Some(window) = self.window.as_ref() { - window.request_redraw(); + let shared = state.shared.borrow(); + if let Some(viewport) = shared.viewports.get(&egui::ViewportId::ROOT) { + viewport.window.request_redraw(); } + drop(shared); self.last_repaint_requested = Instant::now(); } @@ -270,7 +318,7 @@ impl ApplicationHandler for WinitApp { loop { match self.ui_update_rx.try_recv() { Ok(update @ UiUpdate::UpdateRecordingState(active)) => { - if let Some(window) = self.window.as_ref() { + if let Some(window) = state.root_window() { window.set_window_icon(Some(if active { self.recording_icon.clone() } else { @@ -297,16 +345,16 @@ impl ApplicationHandler for WinitApp { event_loop.exit(); } else { // Minimize to tray - if let Some(window) = self.window.as_ref() { + if let Some(window) = state.root_window() { window.set_visible(false); } } } WindowEvent::RedrawRequested => { - self.handle_redraw(); + self.handle_redraw(event_loop, egui::ViewportId::ROOT); } WindowEvent::Resized(new_size) => { - self.handle_resized(new_size.width, new_size.height); + self.handle_resized(egui::ViewportId::ROOT, new_size.width, new_size.height); } _ => (), } @@ -314,4 +362,27 @@ impl ApplicationHandler for WinitApp { // Once the UI has completed its processing, copy out the local config to the AppState. self.main_app.copy_out_local_config(); } + + fn handle_child_window_event(&mut self, viewport_id: egui::ViewportId, event: WindowEvent) { + let Some(state) = self.wgpu_state.as_mut() else { + return; + }; + + // Let egui handle the input event for this viewport + let response = state.handle_input(viewport_id, &event); + + if response.repaint { + let shared = state.shared.borrow(); + if let Some(viewport) = shared.viewports.get(&viewport_id) { + viewport.window.request_redraw(); + } + } + + // For child viewports, we only handle resize events here. + // Close is handled by egui through close_requested() in the viewport callback, + // and redraw is handled via the repaint request above. + if let WindowEvent::Resized(new_size) = event { + state.resize_viewport(viewport_id, new_size.width, new_size.height); + } + } } diff --git a/src/ui/views/main/windows/games.rs b/src/ui/views/main/windows/games.rs index 40f8423..b68db55 100644 --- a/src/ui/views/main/windows/games.rs +++ b/src/ui/views/main/windows/games.rs @@ -1,7 +1,7 @@ use constants::supported_games::{SupportedGame, SupportedGames}; use egui::{ - Align, Button, CollapsingHeader, Color32, Context, CursorIcon, Frame, Label, Layout, RichText, - ScrollArea, Sense, Ui, vec2, + Align, Button, CentralPanel, CollapsingHeader, Color32, Context, CursorIcon, Frame, Label, + Layout, RichText, ScrollArea, Sense, Ui, Vec2, ViewportBuilder, ViewportId, vec2, }; const FONTSIZE: f32 = 13.0; @@ -23,13 +23,26 @@ pub fn window(ctx: &Context, state: &mut GamesWindowState, supported_games: &Sup let (installed, uninstalled): (Vec<_>, Vec<_>) = supported_games.games.iter().partition(|g| g.installed); - let mut should_close = false; + ctx.show_viewport_immediate( + ViewportId::from_hash_of("games_window"), + ViewportBuilder::default() + .with_title("Games") + .with_inner_size(Vec2::new(DEFAULT_WIDTH, DEFAULT_HEIGHT)) + .with_min_inner_size(Vec2::new(300.0, 200.0)), + |ctx, _class| { + render(ctx, state, &installed, &uninstalled); + }, + ); - egui::Window::new("Games") - .default_size([DEFAULT_WIDTH, DEFAULT_HEIGHT]) - .resizable(true) - .open(&mut state.open) - .show(ctx, |ui| { + fn render( + ctx: &Context, + state: &mut GamesWindowState, + installed: &[&SupportedGame], + uninstalled: &[&SupportedGame], + ) { + let mut should_close = false; + + CentralPanel::default().show(ctx, |ui| { ScrollArea::vertical().show(ui, |ui| { // Installed games section if !installed.is_empty() { @@ -79,8 +92,10 @@ pub fn window(ctx: &Context, state: &mut GamesWindowState, supported_games: &Sup }); }); - if should_close { - state.open = false; + // Check if close was requested (either by user action or window close button) + if ctx.input(|i| i.viewport().close_requested()) || should_close { + state.open = false; + } } }