diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 518e17d2c..efa28f266 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -456,20 +456,29 @@ impl SurfaceGeometry { /// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite /// or zero-sized visible surface and recording minimized or occluded state. pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option { - let viewport = raw.viewports.get(&raw.viewport_id)?; + Self::from_viewport(raw.viewports.get(&raw.viewport_id)?, raw.screen_rect, raw.max_texture_side, ctx) + } + + /// [`Self::from_raw`] for one viewport's info, content rectangle and texture limit. + pub(crate) fn from_viewport( + viewport: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + ctx: &egui::Context, + ) -> Option { let minimized = viewport.minimized == Some(true); let occluded = viewport.occluded == Some(true); // A minimized/occluded surface may report no rectangle at all. Its stale size is harmless // because such a surface is never capturable; a visible surface without a rectangle is not // fresh enough to use. - let rect = match viewport.inner_rect.or(raw.screen_rect) { + let rect = match viewport.inner_rect.or(screen_rect) { Some(rect) => rect, None if minimized || occluded => ctx.content_rect(), None => return None, }; let zoom = ctx.zoom_factor(); let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom); - let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); + let max_texture_side = max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 { return None; } @@ -479,9 +488,10 @@ impl SurfaceGeometry { Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded }) } - /// Pairs the surface geometry with the document id a capture belongs to. - pub(crate) fn with_document(self, document: u64) -> CaptureGeometry { - CaptureGeometry { surface: self, document } + /// Pairs the surface geometry with the window's viewport and the document id a capture + /// belongs to. + pub(crate) fn with_target(self, viewport: egui::ViewportId, document: u64) -> CaptureGeometry { + CaptureGeometry { surface: self, viewport, document } } } @@ -491,6 +501,8 @@ impl SurfaceGeometry { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct CaptureGeometry { surface: SurfaceGeometry, + /// The active window's viewport, which the capture command goes to. + viewport: egui::ViewportId, document: u64, } @@ -500,6 +512,18 @@ impl CaptureGeometry { self.document } + /// Why a capture admitted for `viewport` and `document` can't go on against this geometry: + /// another window was brought forward, or the window shows another document. + fn moved_reason(self, viewport: egui::ViewportId, document: u64) -> Option<&'static str> { + if self.viewport != viewport { + Some("screenshot failed: another window was brought forward while the capture was queued") + } else if self.document != document { + Some("screenshot failed: the document changed while the capture was queued") + } else { + None + } + } + /// Explains why the surface cannot be captured, when it is minimized or fully occluded, and /// returns `None` when it can. pub(crate) fn unavailable_reason(self) -> Option<&'static str> { @@ -569,6 +593,9 @@ pub(crate) struct Shot { pub(crate) retries: u32, /// The document the request was admitted for. It never changes; a document switch cancels. pub(crate) document: u64, + /// The viewport of the window the request was admitted for. It never changes; bringing + /// another window forward cancels. + pub(crate) viewport: egui::ViewportId, pub(crate) geometry: CaptureGeometry, pub(crate) epoch: u64, pub(crate) expected: [usize; 2], @@ -623,6 +650,8 @@ fn next_token(app: &mut WordApp) -> u64 { /// earlier request. pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option, reply: Sender) { let token = next_token(app); + // An earlier request in this batch may have brought another window forward. + app.refresh_capture_geometry(ctx); let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else { reject_shot(reply, token, "screenshot failed: the window geometry is not known yet"); return; @@ -684,6 +713,7 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt issued: false, retries: 0, document: geometry.document(), + viewport: geometry.viewport, geometry, epoch: app.capture_epoch, expected, @@ -708,7 +738,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { let shot = &app.shots.requests[i]; (shot.issued, shot.ready_at, shot.frames) }; - if issued { + // Each window issues its own captures, from a pass that drew its layout. + if issued || app.shots.requests[i].viewport != ctx.viewport_id() { continue; } let frames = if first_pass { frames.saturating_add(1) } else { frames }; @@ -724,8 +755,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { failures.push((i, format!("screenshot failed: {reason} while the capture was queued"))); continue; } - if current.document() != app.shots.requests[i].document { - failures.push((i, "screenshot failed: the document changed while the capture was queued".into())); + if let Some(reason) = current.moved_reason(app.shots.requests[i].viewport, app.shots.requests[i].document) { + failures.push((i, reason.into())); continue; } let Some(expected) = current.expected_size() else { @@ -762,7 +793,7 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { shot.bytes = bytes; shot.epoch = epoch; if let Some(callback) = shot.callback.take() { - ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback)); + ctx.send_viewport_cmd_to(shot.viewport, egui::ViewportCommand::Screenshot(callback)); shot.issued = true; } } @@ -778,13 +809,6 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { } } -impl Shot { - /// The screenshot callback's viewport: the root window. - fn viewport(&self) -> egui::ViewportId { - egui::ViewportId::ROOT - } -} - /// A completed image is either stale because the surface changed (safe to retry once within the /// deadline) or invalid for this request (report it at once; retrying cannot help). enum ImageCheck { @@ -850,6 +874,13 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")); } + // Before the issue check: a window that closed never draws to issue its capture. + if let Some(reason) = current.and_then(|geometry| geometry.moved_reason(shot.viewport, shot.document)) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, reason)); + } if !shot.issued { return ShotAction::Keep; } @@ -859,12 +890,6 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))); } - if current.is_some_and(|geometry| geometry.document() != shot.document) { - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - return ShotAction::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")); - } shot.slot.as_ref().and_then(|slot| slot.take()) }; match completed { @@ -1080,7 +1105,7 @@ mod tests { /// Builds a capture geometry fixture for document id 7. fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry { - CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 } + CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), viewport: egui::ViewportId::ROOT, document: 7 } } /// Creates a black test image of the given size. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 191c7ef8a..231b6bc29 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2486,15 +2486,9 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - // Capture geometry/document identity is current before control admission and completion. - // The epoch is monotonic: resizing back to an earlier size is still a new epoch. - let capture_geometry = self.surface_geometry.map(|surface| surface.with_document(self.session.document_id())); - if let Some(geometry) = capture_geometry - && self.capture_geometry != Some(geometry) - { - self.capture_geometry = Some(geometry); - self.capture_epoch = self.capture_epoch.wrapping_add(1); - } + // Capture geometry/window/document identity is current before control admission and + // completion. + self.refresh_capture_geometry(ctx); let native = self.windows.native(); if native { self.printing.poll(ctx); @@ -2733,7 +2727,9 @@ impl WordApp { // Capture the surface from the raw input itself. `InputState::viewport_rect` and the // last painted screen rect are stale in a hidden `run_logic` tick; RawInput is the host's // current answer, including minimized/occluded and a Wayland `inner_rect: None`. - if let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) { + if raw.viewport_id == egui::ViewportId::ROOT + && let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) + { // A hidden resize and change back still invalidate an older capture, even if // neither intermediate geometry reaches a drawing or logic pass. if self.surface_geometry != Some(geometry) { @@ -3101,6 +3097,17 @@ impl WordApp { } } + /// Make the capture geometry the active window's surface, viewport and document. The epoch is + /// monotonic: resizing back to an earlier size is still a new epoch. + pub(crate) fn refresh_capture_geometry(&mut self, ctx: &egui::Context) { + let (viewport, surface) = self.active_surface_geometry(ctx); + let geometry = surface.map(|surface| surface.with_target(viewport, self.session.document_id())); + if self.capture_geometry != geometry { + self.capture_geometry = geometry; + self.capture_epoch = self.capture_epoch.wrapping_add(1); + } + } + /// Drains queued control requests, dispatches each through `control::handle`, sends its reply or /// admits a screenshot, then puts the receiver back. fn drain_control(&mut self, ctx: &egui::Context) { @@ -9753,7 +9760,7 @@ mod tests { let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); let surface = control::SurfaceGeometry::from_raw(&raw, &ctx).expect("screen rect fallback"); - let geometry = surface.with_document(7); + let geometry = surface.with_target(egui::ViewportId::ROOT, 7); assert_eq!(geometry.expected_size(), Some([480, 300])); assert_eq!(geometry.expected_bytes(), Some(480 * 300 * 4)); } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index eafefcc97..bb3e19c9d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -282,6 +282,18 @@ impl WordApp { self.windows.viewport(key) } + /// The active window's viewport and surface, for `ui.screenshot`: the root's from this + /// frame's raw input, another window's from the input it last drew with (the host's raw-input + /// hook sees only the root's). + pub(crate) fn active_surface_geometry(&self, ctx: &egui::Context) -> (ViewportId, Option) { + let viewport = self.active_viewport(); + if viewport == ViewportId::ROOT { + return (viewport, self.surface_geometry); + } + let (info, screen_rect, max_texture_side) = ctx.input_for(viewport, |i| (i.viewport().clone(), i.raw.screen_rect, i.raw.max_texture_side)); + (viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx)) + } + /// The viewport new windows start from: the host's size, icon and app id. Their title and /// place are the app's. pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { @@ -1140,6 +1152,45 @@ mod tests { assert_eq!(text(&a), "First", "the other window got none of it"); } + /// `ui.screenshot` captures the active window: the capture is sized from its viewport and the + /// command goes to that viewport. A capture still pending when another window comes forward + /// fails with an error instead of capturing that window. + #[test] + fn screenshot_captures_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect)); + focus(child); + frames(&ctx, &mut a, 4); + + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "the capture is admitted"); + assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::>(), [(child, [800, 600])]); + a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0); + COMMANDS.with(|c| c.borrow_mut().clear()); + frames(&ctx, &mut a, 4); + let shot_to = + |id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_))))); + assert!(shot_to(child), "the active window is captured"); + assert!(!shot_to(ViewportId::ROOT), "the first window is not"); + + let (reply, later) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + for r in [replies, later] { + let v = r.try_recv().expect("the capture was answered"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}"); + } + assert!(a.shots.requests.is_empty()); + } + /// Quitting with two documents of unsaved changes asks about each in its window, the window /// coming to the front; Cancel keeps both. #[test]