use super::*; impl WaylandPlatform { /// Re-renders the current scene into an offscreen GLES renderbuffer and /// serves the queued screencopy captures from it. See the call site for /// why capture cannot read the window surface directly. pub(super) fn capture_offscreen(&mut self, captures: Vec) -> PlatformResult<()> { use smithay::backend::renderer::{Bind, Offscreen}; let size = self.output.current_mode().map(|m| m.size).unwrap_or_default(); if size.w <= 0 || size.h <= 0 { return Ok(()); } let renderer = self.backend.renderer(); let mut target: smithay::backend::renderer::gles::GlesRenderbuffer = renderer.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into()).map_err(err)?; let mut framebuffer = renderer.bind(&mut target).map_err(err)?; // WHAT THIS PASS STILL DOES NOT DRAW: border strips, and the // desktop icon grid. Everything else the on-screen loop draws is // covered - content, titlebars, shadows, layer-shell surfaces, // popups, both menus, the Snap-Layouts flyout and the drag snap // preview. // // Keep this list honest. The whole reason the nested backend exists // is checking behaviour with `grim`, and a tier missing here makes // a working feature photograph as broken. That has now cost four // separate investigations in one day (popups, shadows, the drag // snap preview, the desktop menu), every one of them starting from // a screenshot that was quietly lying. // Content (with each window's own `opacity`, unlike the // `self.state.space`-based single-alpha call this replaced) and the // bar/dock now render into the capture, at least: a screenshot used // to only ever show titlebars for windows that had one, and never // any layer-shell surface at all. let hide_top_layers = self.wm.borrow().visible_windows_front_to_back().any(|w| w.fullscreen); let mut custom_elements: Vec> = Vec::new(); // Popups first, so they land above everything else - exactly the // order both on-screen render loops already use. // // This pass had no popup step at all, so no tooltip, dropdown or // right-click menu could ever appear in a screenshot taken on this // backend, no matter how correctly it was drawn on screen. That is // a capture-only gap, not a rendering one: the DRM backend serves // screencopy out of its own on-screen frame (`udev/render.rs` // drains `screencopy_pending` and hands `service_pending` the same // framebuffer it just drew), so it never had the gap; this backend // renders the scene a second time into an offscreen buffer, and // that second scene was missing a tier. // // It cost real time to find. The whole point of the nested backend // is validating behaviour with `grim`, and this made `grim` state // the opposite of the truth about every popup: a menu that drew // perfectly on screen photographed as absent, which reads exactly // like the client never opened one. let popup_targets = crate::elements::popup_targets(&self.state); custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, renderer, (0, 0))); // The titlebar context menu and the desktop/desktop-icon menu, both // topmost, in the same order the on-screen loop draws them. for (menu_pos, buffer) in [ (self.state.context_menu.as_ref().map(|m| m.pos), self.state.context_menu_buffer.as_ref()), (self.state.desktop_menu.as_ref().map(|m| m.pos), self.state.desktop_menu_buffer.as_ref()), ] { let (Some(pos), Some(buffer)) = (menu_pos, buffer) else { continue }; match MemoryRenderBufferRenderElement::from_buffer(renderer, (pos.0 as f64, pos.1 as f64), buffer, None, None, None, Kind::Unspecified) { Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), Err(e) => log::warn!("screencopy: failed to import menu buffer: {e}"), } } // The Snap-Layouts flyout and the drag snap preview, in the same // order the on-screen loop draws them. Both are drag-time overlays, // and a screenshot that omits them cannot be used to check either // one - which is exactly how this pass's missing popup tier wasted // a session already. if let (Some(flyout), Some(buffer)) = (self.state.snap_flyout.as_ref(), self.state.snap_flyout_buffer.as_ref()) { let pos = (flyout.pos.0 as f64, flyout.pos.1 as f64); match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) { Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), Err(e) => log::warn!("screencopy: failed to import snap flyout buffer: {e}"), } } if let Some(rect) = self.wm.borrow().drag_snap_preview() { let accent = self.wm.borrow().theme.default_border_color; custom_elements.extend( crate::elements::snap_preview_elements(&mut self.state.snap_preview_buffers, rect, accent, (0, 0)) .into_iter() .map(crate::elements::OverlayElement::Solid), ); } if !hide_top_layers { custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Top | Layer::Overlay))); } // Front-to-back, so a window already pushed occludes everything a // later one draws - accumulated for the shadow clip below, exactly // as both on-screen render loops do it. let monitor_bounds: Vec = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect(); let mut occluders: Vec = Vec::new(); let focused = self.wm.borrow().focused_id(); for id in self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect::>() { // Matches the render loops: a capture must not show a frame the // screen does not (see `window_has_content`). if !crate::state::CompState::has_content(&self.state.awaiting_first_buffer, id) { continue; } let Some(w) = self.wm.borrow().window(id).cloned() else { continue }; if let Some(deco) = self.state.decorations.get(&id) { if let Ok(elem) = MemoryRenderBufferRenderElement::from_buffer(renderer, (w.geometry.x as f64, w.geometry.y as f64), deco, None, None, None, Kind::Unspecified) { custom_elements.push(crate::elements::OverlayElement::Memory(elem)); } } if let Some(dwindow) = self.state.id_to_window.get(&id) { if let Some(surface) = crate::elements::window_wl_surface(dwindow) { let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 }; // `content_offset`: same `xdg_surface.set_window_geometry` // subtraction every other render/capture path in this // codebase already does (`udev/render.rs`, `winit/ // render.rs`, `udev/capture.rs`) - missed here // specifically. A CSD client's invisible shadow margin // landed at `w.geometry.x, w.geometry.y + band` instead // of its real visible content, so a screenshot taken on // this backend showed the same content_offset-sized gap // the on-screen render loops already had fixed. let content_offset = dwindow.geometry().loc; let pos = (w.geometry.x - content_offset.x, w.geometry.y + band - content_offset.y); custom_elements.extend(crate::elements::surface_content_elements(renderer, &surface, pos, w.opacity)); } } // The drop shadow, last in this window's own group so it sits // under its own decoration and content but still over every // window behind it. `shadow_buffers` only holds an entry for a // window that is meant to have one at all (see // `state/lifecycle.rs`), so no separate floating/maximized // check belongs here. // // Shadows were the other half of this pass's missing tier: a // screenshot taken on this backend showed no shadow on any // window, which is exactly the thing a shadow bug gets reported // and re-checked by. Border strips are still absent - a real // remaining gap, called out here rather than left silent. if let Some(shadow) = self.state.shadow_buffers.get(&id) { // `effective_frame`, not `w.geometry` - the same correction // both on-screen render loops apply before sizing any // decoration bitmap (see its own doc comment). The shadow // buffer was rasterised at the *frame's* size, and `src` // below indexes into that buffer, so measuring from // `w.geometry` instead reads the wrong region whenever the // two differ. They differ for exactly the windows that // matter here: a CSD client whose committed surface is not // the size this compositor asked for. Found by sampling // pixels - Alacritty's shadow appeared in a capture and // Nemo's did not, and the only difference was that gap. let frame = self.state.effective_frame(id, w.geometry); let full = crate::decoration::shadow_rect(frame); let rect = crate::decoration::shadow_rect_clipped(frame, &monitor_bounds); for fragment in crate::elements::visible_border_fragments(rect, &occluders) { let src = Rectangle::new( Point::from(((fragment.x - full.x) as f64, (fragment.y - full.y) as f64)), Size::from((fragment.width as f64, fragment.height as f64)), ); match MemoryRenderBufferRenderElement::from_buffer(renderer, (fragment.x as f64, fragment.y as f64), shadow, None, Some(src), None, Kind::Unspecified) { Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), Err(e) => log::warn!("screencopy: failed to import shadow buffer for window {id}: {e}"), } } } // Border strips, as plain solid fills. // // The on-screen loop draws the top and bottom strips from // cached bitmaps so their corners round into the titlebar's // curve, and only the sides as fills. This pass approximates // all four with fills: corner rounding is not reproduced, so a // capture is not pixel-exact at the four corners. Everything // that matters for checking a border - whether one is drawn at // all, where, how thick, and in what colour - is faithful. // // Without this a screenshot could not answer "is there a border // here", which is exactly the question a border bug asks. That // gap was documented and then walked into twice: a maximize // border fix was checked against a capture that never draws // borders, and the control silently passed for the wrong // reason both times. if w.border_width > 0 && !w.maximized { let color = crate::state::effective_border_color(w.border_color, focused == Some(id), self.wm.borrow().theme.border_inactive_dim); let frame = self.state.effective_frame(id, w.geometry); for (index, strip) in crate::decoration::border_strips(frame, w.border_width).into_iter().enumerate() { for fragment in crate::elements::visible_border_fragments(strip, &occluders) { let buf = crate::elements::border_fragment_buffer(&mut self.state.capture_border_buffers, index); custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (0, 0)))); } } } occluders.push(w.geometry); } custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Background | Layer::Bottom))); // A throwaway damage tracker, so this pass always draws the whole // scene (age 0) and never perturbs the on-screen tracker's history. let mut tracker = OutputDamageTracker::from_output(&self.output); tracker .render_output(renderer, &mut framebuffer, 0, &custom_elements, [0.05, 0.05, 0.08, 1.0]) .map_err(err)?; screencopy::service_pending(captures, renderer, &framebuffer); Ok(()) } }