From 8459442fdf504b11916c933a10c995f2630b6b70 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:38:24 +0200 Subject: [PATCH] Fix capture preflight during late output discovery --- CONTRIBUTING.md | 12 +- README.md | 17 + docs/CONFIG.md | 3 +- src/backend/wayland/AGENTS.md | 3 +- .../wayland/backend/event_loop/capture.rs | 136 ++++++- src/backend/wayland/backend/surface.rs | 2 +- src/backend/wayland/capture_preflight.rs | 375 ++++++++++++------ .../wayland/capture_preflight/retry.rs | 208 ++++++++++ src/backend/wayland/frozen/AGENTS.md | 4 +- src/backend/wayland/frozen/capture.rs | 3 +- src/backend/wayland/frozen/portal.rs | 2 +- src/backend/wayland/frozen/retry.rs | 86 ++-- src/backend/wayland/frozen/state.rs | 49 +-- src/backend/wayland/frozen/state/admission.rs | 75 ++++ src/backend/wayland/handlers/compositor.rs | 99 +++-- src/backend/wayland/handlers/output.rs | 98 ++++- src/backend/wayland/handlers/test_support.rs | 252 ++++++++++-- src/backend/wayland/handlers/xdg.rs | 35 +- src/backend/wayland/state/AGENTS.md | 4 +- src/backend/wayland/state/capture/backdrop.rs | 7 - src/backend/wayland/state/capture/barrier.rs | 18 +- src/backend/wayland/state/capture/retry.rs | 300 ++++++++++---- src/backend/wayland/state/core/output.rs | 49 +++ .../wayland/state/core/output/focus.rs | 82 +++- src/backend/wayland/surface.rs | 190 ++------- .../wayland/surface/output_membership.rs | 139 +++++++ src/backend/wayland/surface/placement.rs | 152 +++++++ src/backend/wayland/zoom/AGENTS.md | 3 +- src/backend/wayland/zoom/capture.rs | 3 +- src/backend/wayland/zoom/portal.rs | 4 +- src/backend/wayland/zoom/retry.rs | 86 ++-- src/backend/wayland/zoom/state.rs | 71 +++- src/backend/wayland/zoom/state/source.rs | 6 +- 33 files changed, 1952 insertions(+), 621 deletions(-) create mode 100644 src/backend/wayland/capture_preflight/retry.rs create mode 100644 src/backend/wayland/frozen/state/admission.rs create mode 100644 src/backend/wayland/surface/output_membership.rs create mode 100644 src/backend/wayland/surface/placement.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8c495fe..cd51dec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,10 +215,14 @@ not collected by CI. Freeze and Zoom fail when the active output does not advertise a current `wl_output` mode. Changes confined to another monitor preserve direct captures and installed active-output sources. -Portal captures discard stale desktop snapshots and retry once after complete geometry settles, -while preserving the original output identity and request. A second change during the retry or an -active-output switch is terminal. Board PDF desktop captures retain a full-desktop generation check, -including changes to other monitors even when the screenshot dimensions stay the same. +Capture preflight retries once on the selected backend after its required geometry settles, +while preserving the original request and keyboard policy. Direct backends require only active-output +geometry; portal captures require complete desktop geometry and also retry stale desktop snapshots. +Known output identities stay bound; requests started before +output discovery bind to the first output observed during the bounded wait. A second stale capture, +loss of a bound output, or an active-output switch is terminal. Board PDF desktop captures retain a +full-desktop generation check, including changes to other monitors even when the screenshot +dimensions stay the same. `dotnet run tools/wayscriber.cs --no-build -- report code-health` reports navigational maintainability metrics. Its CI artifact is diff --git a/README.md b/README.md index 4b998b46..a30d9ff9 100644 --- a/README.md +++ b/README.md @@ -1297,6 +1297,23 @@ ls -la ~/.config/wayscriber/config.toml RUST_LOG=info wayscriber --active # watch for TOML errors ``` +### Freeze and Zoom capture logs + +`--active` and `--daemon` write dated logs to `$XDG_DATA_HOME/wayscriber/logs/`, +normally `~/.local/share/wayscriber/logs/`. Daemon-spawned overlays also write there; +forwarding their stdout or stderr is not required. `WAYSCRIBER_LOG_FILE` overrides +the log destination. + +At the default `info` level, `capture.output` records surface enter/leave events with +previous and current output IDs and names. `capture.preflight` records Freeze/Zoom, +the barrier ID, saved and current output/layout tokens, and retry decisions. Monitor +metadata updates are available at `debug` level. + +For full output metadata and before/after geometry, set +`RUST_LOG=info,wayscriber::capture_diagnostics=trace` +in the daemon's startup environment. Restart the daemon after changing its environment +so newly spawned overlays inherit the filter. + ### Environment variables Common toggles: diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 37337601..5ac1cb57 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2683,7 +2683,8 @@ These override behavior at runtime. Bool-ish values treat anything except `0`, ` - `WAYSCRIBER_XDG_OUTPUT=...` forces GNOME fallback overlays onto a specific output (overrides `ui.preferred_output`) - `WAYSCRIBER_XDG_FULLSCREEN=1` requests fullscreen GNOME fallback overlays (overrides `ui.xdg_fullscreen`) - `WAYSCRIBER_XDG_FULLSCREEN_FORCE=1` bypasses the GNOME opacity safety check -- `RUST_LOG=info` enables Rust logging (default: unset; use `wayscriber=debug` for app-level logs) +- `RUST_LOG` sets the Rust log filter (default when unset: `info`; use `wayscriber=debug` for app-level logs or `info,wayscriber::capture_diagnostics=trace` for capture diagnostics) +- `WAYSCRIBER_LOG_FILE=/path/to/wayscriber.log` overrides the file log destination for `--active` and `--daemon` (default: dated files in `$XDG_DATA_HOME/wayscriber/logs/`, normally `~/.local/share/wayscriber/logs/`) ## Troubleshooting diff --git a/src/backend/wayland/AGENTS.md b/src/backend/wayland/AGENTS.md index cfd1f572..1a94afb9 100644 --- a/src/backend/wayland/AGENTS.md +++ b/src/backend/wayland/AGENTS.md @@ -9,8 +9,9 @@ - `handlers/` translates Wayland/Smithay callbacks into state and input calls. - `state.rs` is the central live runtime state root; `state/` contains state helpers for boards, buffers, damage, clipboard paste, toolbar plumbing, zoom, onboarding, and export handoff. - `toolbar/` owns runtime toolbar layout, hit testing, rows, surfaces, rendering, and widgets. +- `surface.rs` owns the main overlay surface, its buffers, and its shell role. `surface/output_membership.rs` selects the current output from compositor enter/leave, explicit output requests, and the provisional XDG configure guess that the first enter replaces; `surface/placement.rs` owns layer policy and bounded XDG frozen-fullscreen placement. - `portal_raster.rs` validates complete portal desktop snapshots, crops the active logical rectangle, and resamples it to transformed native output pixels. `frozen_geometry.rs` keeps active viewport validity separate from full desktop validity. -- `capture_preflight.rs` retains backend-specific admission, shared active-output/full-desktop generation bookkeeping, typed preflight failures, and the bounded portal layout settling/retry policy. Board PDF desktop contexts use the full-desktop generation; direct capture and installed sources use the active-output generation. +- `capture_preflight.rs` retains backend-specific admission, shared active-output/full-desktop generation bookkeeping, and typed preflight failures. `capture_preflight/retry.rs` owns bounded layout settling and re-admission on the retained backend. Board PDF desktop contexts use the full-desktop generation; direct capture and installed sources use the active-output generation. - `clipboard/`, `frozen/`, `zoom/`, and runtime session helpers integrate external compositor, clipboard, capture, and persistence behavior. ## Invariants diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index dea8a256..63c70214 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -39,7 +39,7 @@ pub(super) fn poll_capture_deadlines( ) { // Re-admit retries after dispatch, before GTK synchronization and rendering. // This lets a fresh GTK generation be published in the same iteration. - state.poll_portal_layout_retries(now); + state.poll_layout_retries(now); state.poll_overlay_capture_barrier_timeout(now); if let Some(backend) = state.frozen.take_timed_out_direct_capture(now) { warn!("{backend:?} frozen capture timed out; trying the next backend"); @@ -53,8 +53,8 @@ pub(super) fn capture_timeout( render_delay: Duration, ) -> Option { [ - state.frozen.portal_layout_retry_timeout(now), - state.zoom.portal_layout_retry_timeout(now), + state.frozen.layout_retry_timeout(now), + state.zoom.layout_retry_timeout(now), state.overlay_capture_barrier_timeout(now, render_delay), state.frozen.direct_capture_timeout(now), state.frozen.portal_timeout(now), @@ -716,6 +716,136 @@ fn handle_capture_manager_failure( mod tests { use super::*; use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; + use crate::backend::wayland::handlers::test_support::{CaptureFixtureBackend, HandlerFixture}; + + #[test] + fn toolbar_capture_retries_output_discovery_through_both_hidden_frames() { + use crate::ui::toolbar::ToolbarEvent; + + for (event, backend) in [ + (ToolbarEvent::ToggleFreeze, CaptureFixtureBackend::Portal), + (ToolbarEvent::ZoomIn, CaptureFixtureBackend::Portal), + ( + ToolbarEvent::ToggleFreeze, + CaptureFixtureBackend::ExtImageCopy, + ), + ( + ToolbarEvent::ToggleFreeze, + CaptureFixtureBackend::WlrScreencopy, + ), + (ToolbarEvent::ZoomIn, CaptureFixtureBackend::WlrScreencopy), + ] { + for enter_before_frame in [false, true] { + toolbar_output_discovery(event.clone(), enter_before_frame, backend); + } + } + } + + fn toolbar_output_discovery( + event: crate::ui::toolbar::ToolbarEvent, + enter_before_frame: bool, + backend: CaptureFixtureBackend, + ) { + use smithay_client_toolkit::compositor::CompositorHandler; + + let mut fixture = + HandlerFixture::with_capture_output(crate::config::Config::default(), backend); + let output = fixture.complete_output_metadata("DP-3"); + let qh = fixture.queue.handle(); + let surface = fixture.state.surface.wl_surface().unwrap().clone(); + + fixture + .state + .handle_toolbar_event(event.clone(), Some(&fixture.conn), Some(&qh)); + handle_pending_actions(&mut fixture.state, &qh); + + let acquisition = fixture.state.acquisition.slot(); + let zoom_id = fixture.state.zoom.current_capture_id(); + let reason = fixture.state.suppression.reason(); + let keyboard_passthrough = fixture + .state + .suppression + .keyboard_passthrough_requested(false); + let freezing = matches!(event, crate::ui::toolbar::ToolbarEvent::ToggleFreeze); + assert_eq!( + reason, + if freezing { + OverlaySuppression::Frozen + } else { + OverlaySuppression::Zoom + } + ); + assert_eq!(acquisition.is_some(), freezing); + assert_eq!(zoom_id.is_some(), !freezing); + + if enter_before_frame { + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &output); + } + complete_hidden_frame(&mut fixture, &qh); + assert_eq!(fixture.state.frozen.has_layout_retry(), freezing); + assert_eq!(fixture.state.zoom.has_layout_retry(), !freezing); + + if !enter_before_frame { + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &output); + } + + // Repeated KWin enter notifications during settling must retain ownership. + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &output); + assert_eq!(fixture.state.acquisition.slot(), acquisition); + assert_eq!(fixture.state.zoom.current_capture_id(), zoom_id); + + let now = Instant::now(); + poll_capture_deadlines(&mut fixture.state, &qh, now); + poll_capture_deadlines(&mut fixture.state, &qh, now + Duration::from_millis(150)); + assert!(!fixture.state.frozen.has_layout_retry()); + assert!(!fixture.state.zoom.has_layout_retry()); + assert_eq!(fixture.state.suppression.reason(), reason); + assert_eq!( + fixture + .state + .suppression + .keyboard_passthrough_requested(false), + keyboard_passthrough + ); + complete_hidden_frame(&mut fixture, &qh); + + // Both barriers completed and the worker was admitted with the original request. + // The fixture's current-thread Tokio runtime is never polled, so no host portal runs. + assert_eq!(fixture.state.acquisition.slot(), acquisition); + assert_eq!(fixture.state.zoom.current_capture_id(), zoom_id); + assert_eq!(fixture.state.suppression.reason(), reason); + assert_capture_started(&fixture.state, freezing); + fixture.assert_direct_capture_started(); + + fixture.state.frozen.cancel(&mut fixture.state.input_state); + fixture.state.zoom.abort_capture(); + } + + fn assert_capture_started(state: &WaylandState, freezing: bool) { + assert!(!state.frozen.preflight_pending()); + assert!(!state.zoom.preflight_pending()); + assert_eq!(state.frozen.is_in_progress(), freezing); + assert_eq!(state.zoom.is_in_progress(), !freezing); + assert!(!state.frozen.has_layout_retry()); + assert!(!state.zoom.has_layout_retry()); + } + + fn complete_hidden_frame( + fixture: &mut crate::backend::wayland::handlers::test_support::HandlerFixture, + qh: &wayland_client::QueueHandle, + ) { + assert!(matches!( + fixture.state.render(qh).unwrap(), + crate::backend::wayland::state::RenderOutcome::Committed { .. } + )); + fixture.complete_main_frame(); + } fn record( owner: ScreenAcquisitionOwner, diff --git a/src/backend/wayland/backend/surface.rs b/src/backend/wayland/backend/surface.rs index 1788f3c8..675e27b9 100644 --- a/src/backend/wayland/backend/surface.rs +++ b/src/backend/wayland/backend/surface.rs @@ -38,7 +38,7 @@ pub(super) fn create_overlay_surface( // Commit the surface layer_surface.commit(); - state.surface.set_layer_surface(layer_surface); + state.surface.set_layer_surface(layer_surface, None); state .focus .set_keyboard_interactivity(Some(desired_keyboard_mode)); diff --git a/src/backend/wayland/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index 62048be4..d1c035bb 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -1,11 +1,58 @@ //! Capture admission and the layout identity retained through fallback. -use std::time::{Duration, Instant}; +mod retry; + +pub(super) use retry::{CaptureLayoutRetry, LayoutRetryError}; use super::frozen_geometry::OutputGeometry; +use super::state::OverlaySuppression; + +/// Stable target for capture diagnostics. It stays under the crate-wide +/// `wayscriber` filter without matching the `wayscriber::capture` module filter. +pub(super) const CAPTURE_LOG_TARGET: &str = "wayscriber::capture_diagnostics"; + +#[derive(Clone, Copy)] +pub(super) enum CaptureLayoutScope { + ActiveOutput, + Desktop, +} + +impl CaptureLayoutScope { + pub(super) fn generation(self, generations: CaptureLayoutGenerations) -> u64 { + match self { + Self::ActiveOutput => generations.active_output, + Self::Desktop => generations.desktop, + } + } -const PORTAL_LAYOUT_QUIET_PERIOD: Duration = Duration::from_millis(100); -const PORTAL_LAYOUT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); -const INCOMPLETE_LAYOUT_POLL_INTERVAL: Duration = Duration::from_millis(50); + fn is_complete(self, geometry: &OutputGeometry) -> bool { + match self { + Self::ActiveOutput => geometry.verified_pixel_size().is_some(), + Self::Desktop => geometry.portal_layout_is_complete(), + } + } +} + +pub(super) trait CaptureBackend: Copy + std::fmt::Debug { + fn layout_scope(self) -> CaptureLayoutScope; + fn suppression_reason(self) -> OverlaySuppression; + + fn layout_generation(self, generations: CaptureLayoutGenerations) -> u64 { + self.layout_scope().generation(generations) + } +} + +/// Formats an optional log value as the bare value or `none`, keeping +/// `key=value` diagnostics free of `Some(..)` wrappers. +pub(super) struct LogField(pub(super) Option); + +impl std::fmt::Display for LogField { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + Some(value) => value.fmt(formatter), + None => formatter.write_str("none"), + } + } +} /// Direct/installed pixels depend on the active viewport; desktop captures also /// depend on other outputs and the screenshot's crop bounds. @@ -21,12 +68,21 @@ impl CaptureLayoutGenerations { before: Option<&OutputGeometry>, after: Option<&OutputGeometry>, ) { - if before != after { + let desktop_changed = before != after; + if desktop_changed { self.desktop = self.desktop.wrapping_add(1); } if !OutputGeometry::same_active_output(before, after) { self.active_output = self.active_output.wrapping_add(1); } + + if desktop_changed { + log::trace!(target: CAPTURE_LOG_TARGET, + "capture.layout phase=changed active_output_generation={} desktop_generation={} before={before:?} after={after:?}", + self.active_output, + self.desktop + ); + } } } @@ -74,11 +130,11 @@ impl std::fmt::Display for CapturePreflightError { impl std::error::Error for CapturePreflightError {} -impl From for CapturePreflightError { - fn from(error: PortalRetryError) -> Self { +impl From for CapturePreflightError { + fn from(error: LayoutRetryError) -> Self { match error { - PortalRetryError::OutputChanged => Self::StaleLayout, - PortalRetryError::LayoutDidNotSettle => Self::LayoutDidNotSettle, + LayoutRetryError::OutputChanged => Self::StaleLayout, + LayoutRetryError::LayoutDidNotSettle => Self::LayoutDidNotSettle, } } } @@ -118,6 +174,18 @@ impl CapturePreflight { matches!(self, Self::Pending { .. }) } + pub(super) fn awaiting_output(&self) -> bool { + self.layout() + .is_some_and(|layout| layout.output_id.is_none()) + } + + fn layout(&self) -> Option<&CaptureLayout> { + match self { + Self::Idle => None, + Self::Pending { layout, .. } | Self::Capturing { layout, .. } => Some(layout), + } + } + pub(super) fn take_pending(&mut self) -> Option { let Self::Pending { backend, layout } = *self else { return None; @@ -135,21 +203,55 @@ impl CapturePreflight { } } - pub(super) fn changed_on_output(&self, output_id: Option, generation: u64) -> bool { - let layout = match self { - Self::Idle => return false, - Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, + pub(super) fn generation(&self, generations: CaptureLayoutGenerations) -> u64 + where + B: CaptureBackend, + { + self.backend().map_or(generations.active_output, |backend| { + backend.layout_generation(generations) + }) + } + + /// Before acquisition starts, an unknown target may bind to its first + /// output. A request already bound to an output must never move to another. + pub(super) fn can_retry_on_output(&self, output_id: Option, generation: u64) -> bool { + let Some(layout) = self.layout() else { + return false; }; - layout.output_id.is_some() - && layout.output_id == output_id - && layout.generation != generation + match (layout.output_id, output_id) { + (None, _) => true, + (Some(captured), Some(active)) => captured == active && layout.generation != generation, + _ => false, + } + } + + pub(super) fn log_layout( + &self, + phase: &'static str, + barrier_id: Option, + active_output: Option, + generations: CaptureLayoutGenerations, + ) where + B: CaptureBackend, + { + if let (Some(backend), Some(layout)) = (self.backend(), self.layout()) { + let reason = backend.suppression_reason(); + let current_generation = backend.layout_generation(generations); + + log::info!(target: CAPTURE_LOG_TARGET, + "capture.preflight id={} component=layout reason={reason:?} phase={phase} backend={backend:?} captured_output={} active_output={} captured_generation={} current_generation={current_generation}", + LogField(barrier_id), + LogField(layout.output_id), + LogField(active_output), + layout.generation + ); + } } pub(super) fn layout_matches(&self, output_id: Option, generation: u64) -> bool { - let layout = match self { - Self::Idle => return true, - Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, + let Some(layout) = self.layout() else { + return true; }; super::portal_capture::layout_token_matches( @@ -171,121 +273,126 @@ impl CapturePreflight { Err(CapturePreflightError::StaleLayout) } } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PortalRetryError { - OutputChanged, - LayoutDidNotSettle, -} - -/// One retry retained by the original request while its portal result is discarded. -#[derive(Debug, Clone, Copy, Default)] -pub(super) enum PortalLayoutRetry { - #[default] - Available, - Pending { - output_id: u32, - generation: u64, - not_before: Instant, - deadline: Instant, - }, - Spent, -} -impl PortalLayoutRetry { - pub(super) fn schedule( - &mut self, - captured: Option, - active: Option, - layout_changed: bool, + pub(super) fn ensure_admission_current( + &self, + output_id: Option, generation: u64, - now: Instant, - ) -> bool { - let Some(output_id) = captured else { - return false; - }; - if !matches!(self, Self::Available) || active != captured || !layout_changed { - return false; + ) -> Result<(), CapturePreflightError> { + if output_id.is_none() { + return Err(CapturePreflightError::StaleLayout); } - *self = Self::Pending { - output_id, - generation, - not_before: now + PORTAL_LAYOUT_QUIET_PERIOD, - deadline: now + PORTAL_LAYOUT_SETTLE_TIMEOUT, - }; - - true - } - - pub(super) fn is_pending(&self) -> bool { - matches!(self, Self::Pending { .. }) + self.ensure_layout_current(output_id, generation) } +} - pub(super) fn timeout(&self, now: Instant) -> Option { - let Self::Pending { - not_before, - deadline, - .. - } = *self - else { - return None; - }; +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; - Some(not_before.min(deadline).saturating_duration_since(now)) + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum TestBackend { + Portal, + Direct, } - /// Admit only a complete snapshot after a quiet period. Output events can - /// arrive in multiple batches; a deadline bounds metadata that never settles. - pub(super) fn take_ready( - &mut self, - active_output: Option, - active_generation: u64, - geometry: Option<&OutputGeometry>, - now: Instant, - ) -> Result, PortalRetryError> { - let Self::Pending { - output_id, - generation, - not_before, - deadline, - } = self - else { - return Ok(None); - }; - if active_output != Some(*output_id) { - *self = Self::Spent; - return Err(PortalRetryError::OutputChanged); - } - // Admission itself is bounded, even if dispatch wakes late after the - // geometry's quiet period. Never start a new barrier after this deadline. - if now >= *deadline { - *self = Self::Spent; - return Err(PortalRetryError::LayoutDidNotSettle); + impl CaptureBackend for TestBackend { + fn layout_scope(self) -> CaptureLayoutScope { + match self { + Self::Portal => CaptureLayoutScope::Desktop, + Self::Direct => CaptureLayoutScope::ActiveOutput, + } } - if active_generation != *generation { - *generation = active_generation; - *not_before = now + PORTAL_LAYOUT_QUIET_PERIOD; + fn suppression_reason(self) -> OverlaySuppression { + OverlaySuppression::Frozen } - if now < *not_before { - return Ok(None); + } + + fn generations(generation: u64) -> CaptureLayoutGenerations { + CaptureLayoutGenerations { + active_output: generation, + desktop: generation, } - if !geometry.is_some_and(OutputGeometry::portal_layout_is_complete) { - *not_before = now + INCOMPLETE_LAYOUT_POLL_INTERVAL; - return Ok(None); + } + + #[test] + fn direct_retry_does_not_wait_for_unrelated_output_metadata() { + let now = Instant::now(); + let incomplete_desktop = geometry().with_known_output_count(Some(2)); + let mut retry = CaptureLayoutRetry::default(); + assert!(retry.schedule(TestBackend::Direct, Some(1), Some(1), true, 4, now)); + + assert_eq!( + retry.take_ready( + Some(1), + CaptureLayoutGenerations { + active_output: 4, + desktop: 5 + }, + Some(&incomplete_desktop), + now + Duration::from_millis(100) + ), + Ok(Some((TestBackend::Direct, 1))) + ); + } + + #[test] + fn preflight_retry_keeps_known_targets_and_allows_output_discovery() { + for (captured, active, generation, expected) in [ + (None, None, 4, true), + (None, Some(1), 4, true), + (Some(1), None, 5, false), + (Some(1), Some(2), 5, false), + (Some(1), Some(1), 4, false), + (Some(1), Some(1), 5, true), + ] { + let mut preflight = CapturePreflight::default(); + preflight.begin((), captured, 4); + + assert_eq!(preflight.can_retry_on_output(active, generation), expected); + + preflight.take_pending(); + assert_eq!(preflight.can_retry_on_output(active, generation), expected); } - let output_id = *output_id; - *self = Self::Spent; - Ok(Some(output_id)) + assert!(!CapturePreflight::<()>::Idle.can_retry_on_output(Some(1), 5)); } -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn retry_waits_for_a_first_output_then_keeps_that_identity() { + let now = Instant::now(); + let geometry = geometry(); + let mut unbound = CapturePreflight::default(); + unbound.begin(TestBackend::Portal, None, 4); + let mut retry = CaptureLayoutRetry::default(); + assert!(retry.queue_preflight(&unbound, None, generations(4), now)); + + assert_eq!(retry.take_ready(None, generations(4), None, now), Ok(None)); + assert_eq!( + retry.take_ready(Some(1), generations(5), Some(&geometry), now), + Ok(None) + ); + assert_eq!( + retry.take_ready( + Some(2), + generations(5), + Some(&geometry), + now + Duration::from_millis(150) + ), + Err(LayoutRetryError::OutputChanged) + ); + + let mut retry = CaptureLayoutRetry::default(); + assert!(retry.queue_preflight(&unbound, None, generations(4), now)); + + assert_eq!( + retry.take_ready(None, generations(4), None, now + Duration::from_secs(1)), + Err(LayoutRetryError::LayoutDidNotSettle) + ); + } fn geometry() -> OutputGeometry { OutputGeometry::update_from( @@ -304,18 +411,23 @@ mod tests { fn retry_waits_for_both_output_update_batches_and_spends_the_budget_once() { let now = Instant::now(); let geometry = geometry(); - let mut retry = PortalLayoutRetry::default(); - assert!(retry.schedule(Some(1), Some(1), true, 4, now)); + let mut retry = CaptureLayoutRetry::default(); + assert!(retry.schedule(TestBackend::Portal, Some(1), Some(1), true, 4, now)); assert_eq!(retry.timeout(now), Some(Duration::from_millis(100))); assert_eq!( - retry.take_ready(Some(1), 4, Some(&geometry), now + Duration::from_millis(99)), + retry.take_ready( + Some(1), + generations(4), + Some(&geometry), + now + Duration::from_millis(99) + ), Ok(None) ); assert_eq!( retry.take_ready( Some(1), - 5, + generations(5), Some(&geometry), now + Duration::from_millis(100) ), @@ -328,7 +440,7 @@ mod tests { assert_eq!( retry.take_ready( Some(1), - 5, + generations(5), Some(&geometry), now + Duration::from_millis(199) ), @@ -337,27 +449,27 @@ mod tests { assert_eq!( retry.take_ready( Some(1), - 5, + generations(5), Some(&geometry), now + Duration::from_millis(200) ), - Ok(Some(1)) + Ok(Some((TestBackend::Portal, 1))) ); assert_eq!(retry.timeout(now + Duration::from_millis(200)), None); - assert!(!retry.schedule(Some(1), Some(1), true, 6, now)); + assert!(!retry.schedule(TestBackend::Portal, Some(1), Some(1), true, 6, now)); } #[test] fn incomplete_topology_wait_is_paced_and_bounded() { let now = Instant::now(); let incomplete = geometry().with_known_output_count(Some(2)); - let mut retry = PortalLayoutRetry::default(); - assert!(retry.schedule(Some(1), Some(1), true, 4, now)); + let mut retry = CaptureLayoutRetry::default(); + assert!(retry.schedule(TestBackend::Portal, Some(1), Some(1), true, 4, now)); assert_eq!( retry.take_ready( Some(1), - 4, + generations(4), Some(&incomplete), now + Duration::from_millis(100) ), @@ -369,7 +481,12 @@ mod tests { ); assert!( retry - .take_ready(Some(1), 4, Some(&incomplete), now + Duration::from_secs(1)) + .take_ready( + Some(1), + generations(4), + Some(&incomplete), + now + Duration::from_secs(1) + ) .is_err() ); assert!(!retry.is_pending()); diff --git a/src/backend/wayland/capture_preflight/retry.rs b/src/backend/wayland/capture_preflight/retry.rs new file mode 100644 index 00000000..de35bd3f --- /dev/null +++ b/src/backend/wayland/capture_preflight/retry.rs @@ -0,0 +1,208 @@ +//! One retained retry on the backend that failed admission or produced stale pixels. +use super::*; +use std::time::{Duration, Instant}; + +const LAYOUT_QUIET_PERIOD: Duration = Duration::from_millis(100); +const LAYOUT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); +const INCOMPLETE_LAYOUT_POLL_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum LayoutRetryError { + OutputChanged, + LayoutDidNotSettle, +} + +/// One retry retained by the original request while admission or portal pixels settle. +#[derive(Debug, Clone, Copy, Default)] +pub(in crate::backend::wayland) enum CaptureLayoutRetry { + #[default] + Available, + Pending { + backend: B, + output_id: Option, + generation: u64, + not_before: Instant, + deadline: Instant, + }, + Spent, +} + +impl CaptureLayoutRetry { + fn log_decision(&self, backend: B, eligible: bool, queued: bool) { + let state = match self { + Self::Available => "available", + Self::Pending { .. } => "settling", + Self::Spent => "spent", + }; + let reason = backend.suppression_reason(); + + log::info!(target: CAPTURE_LOG_TARGET, + "capture.preflight component=layout reason={reason:?} phase=retry-decision backend={backend:?} eligible={eligible} queued={queued} retry_state={state} budget_remaining={}", + u8::from(matches!(self, Self::Available)) + ); + } + + pub(in crate::backend::wayland) fn schedule( + &mut self, + backend: B, + captured: Option, + active: Option, + layout_changed: bool, + generation: u64, + now: Instant, + ) -> bool { + if captured.is_none() || active != captured || !layout_changed { + return false; + } + + self.begin_once(backend, captured, generation, now) + } + + /// Only preflight admission may wait without an identity. A portal result + /// with no target is invalid and goes through `schedule`, which rejects it. + pub(in crate::backend::wayland) fn queue_preflight( + &mut self, + preflight: &CapturePreflight, + active: Option, + generations: CaptureLayoutGenerations, + now: Instant, + ) -> bool { + let Some(backend) = preflight.backend() else { + return false; + }; + + let generation = backend.layout_generation(generations); + let eligible = preflight.can_retry_on_output(active, generation); + let queued = eligible && self.begin_once(backend, active, generation, now); + + self.log_decision(backend, eligible, queued); + + queued + } + + /// Spends the single retry budget; a pending or spent retry never restarts. + fn begin_once( + &mut self, + backend: B, + output_id: Option, + generation: u64, + now: Instant, + ) -> bool { + if !matches!(self, Self::Available) { + return false; + } + + *self = Self::Pending { + backend, + output_id, + generation, + not_before: now + LAYOUT_QUIET_PERIOD, + deadline: now + LAYOUT_SETTLE_TIMEOUT, + }; + + true + } + + pub(in crate::backend::wayland) fn restart_preflight( + &mut self, + preflight: &mut CapturePreflight, + active: Option, + generations: CaptureLayoutGenerations, + geometry: Option<&OutputGeometry>, + now: Instant, + ) -> Result { + let Some((backend, output_id)) = self.take_ready(active, generations, geometry, now)? + else { + return Ok(false); + }; + + preflight.begin( + backend, + Some(output_id), + backend.layout_generation(generations), + ); + preflight.log_layout("retry-preflight", None, active, generations); + + Ok(true) + } + + pub(in crate::backend::wayland) fn is_pending(&self) -> bool { + matches!(self, Self::Pending { .. }) + } + + pub(in crate::backend::wayland) fn timeout(&self, now: Instant) -> Option { + let Self::Pending { + not_before, + deadline, + .. + } = *self + else { + return None; + }; + + Some(not_before.min(deadline).saturating_duration_since(now)) + } + + /// Admit only a complete snapshot after a quiet period. Output events can + /// arrive in multiple batches; a deadline bounds metadata that never settles. + pub(in crate::backend::wayland) fn take_ready( + &mut self, + active_output: Option, + generations: CaptureLayoutGenerations, + geometry: Option<&OutputGeometry>, + now: Instant, + ) -> Result, LayoutRetryError> { + let Self::Pending { + backend, + output_id, + generation, + not_before, + deadline, + } = self + else { + return Ok(None); + }; + + if output_id.is_some() && active_output != *output_id { + *self = Self::Spent; + return Err(LayoutRetryError::OutputChanged); + } + + // Admission itself is bounded, even if dispatch wakes late after the + // geometry's quiet period. Never start a new barrier after this deadline. + if now >= *deadline { + *self = Self::Spent; + return Err(LayoutRetryError::LayoutDidNotSettle); + } + + if output_id.is_none() { + let Some(active) = active_output else { + *not_before = now + INCOMPLETE_LAYOUT_POLL_INTERVAL; + return Ok(None); + }; + + *output_id = Some(active); + *not_before = now + LAYOUT_QUIET_PERIOD; + } + + let active_generation = backend.layout_generation(generations); + if active_generation != *generation { + *generation = active_generation; + *not_before = now + LAYOUT_QUIET_PERIOD; + } + + if now < *not_before { + return Ok(None); + } + + if !geometry.is_some_and(|geometry| backend.layout_scope().is_complete(geometry)) { + *not_before = now + INCOMPLETE_LAYOUT_POLL_INTERVAL; + return Ok(None); + } + + let target = output_id.map(|output_id| (*backend, output_id)); + *self = Self::Spent; + + Ok(target) + } +} diff --git a/src/backend/wayland/frozen/AGENTS.md b/src/backend/wayland/frozen/AGENTS.md index 55f7ee84..cf603cd3 100644 --- a/src/backend/wayland/frozen/AGENTS.md +++ b/src/backend/wayland/frozen/AGENTS.md @@ -7,8 +7,8 @@ - `capture.rs` coordinates frozen capture setup. - `image.rs` owns frozen image data handling. - `portal.rs` provides portal-based fallback behavior. -- `state.rs` tracks frozen-mode state and acquisition ownership. `state/activation.rs` validates and installs completed output images and owns their resize, unfreeze, and cancellation lifecycle; `state/direct.rs` owns direct attempt resources and deadlines. -- `retry.rs` retains the original Freeze acquisition through the shared portal settling policy and fresh preflight. +- `state.rs` tracks frozen-mode state and acquisition ownership. `state/admission.rs` owns backend-specific admission and diagnostics; `state/activation.rs` validates and installs completed output images and owns their resize, unfreeze, and cancellation lifecycle; `state/direct.rs` owns direct attempt resources and deadlines. +- `retry.rs` retains the original Freeze acquisition and selected backend through the shared layout settling policy and fresh preflight. ## Invariants - Preserve overlay-hide, capture, restore, and render ordering. diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index 113c6543..37c26847 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -163,8 +163,9 @@ impl FrozenState { + Dispatch + 'static, { - self.ensure_preflight_layout_current() + self.ensure_preflight_admission() .map_err(anyhow::Error::msg)?; + let mut backend = Some(first_backend); let mut last_error = None; diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index 2ec2f854..08af02b1 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -345,7 +345,7 @@ mod tests { Some(2), ); - assert!(frozen.has_portal_layout_retry()); + assert!(frozen.has_layout_retry()); assert!(frozen.has_acquisition_attempt()); assert!(!frozen.has_pending_image()); assert!(frozen.take_acquisition_completion().is_none()); diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs index e1cec89c..bcdce726 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -9,6 +9,7 @@ impl FrozenState { layout_changed: bool, ) -> bool { if !self.layout_retry.schedule( + FrozenCaptureBackend::Portal, captured, self.active_output_id, layout_changed, @@ -18,9 +19,7 @@ impl FrozenState { return false; } - self.portal.finish(); - self.discard_pending_image_for_retry(); - self.capture_done = false; + self.reset_for_retry(); log::info!( "portal.freeze phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", self.layout_generations.desktop @@ -29,22 +28,32 @@ impl FrozenState { true } - pub(in crate::backend::wayland) fn retry_stale_portal_preflight( - &mut self, - backend: FrozenCaptureBackend, - ) -> bool { - let portal_layout_changed = backend == FrozenCaptureBackend::Portal - && self - .preflight - .changed_on_output(self.active_output_id, self.layout_generations.desktop); - self.queue_portal_layout_retry(self.active_output_id, portal_layout_changed) + pub(in crate::backend::wayland) fn retry_stale_preflight(&mut self) -> bool { + let queued = self.layout_retry.queue_preflight( + &self.preflight, + self.active_output_id, + self.layout_generations, + Instant::now(), + ); + if queued { + self.reset_for_retry(); + } + + queued } - pub(in crate::backend::wayland) fn has_portal_layout_retry(&self) -> bool { + /// Drops the attempt the retry replaces; the request and its waiter stay. + fn reset_for_retry(&mut self) { + self.portal.finish(); + self.discard_pending_image_for_retry(); + self.capture_done = false; + } + + pub(in crate::backend::wayland) fn has_layout_retry(&self) -> bool { self.layout_retry.is_pending() } - pub(in crate::backend::wayland) fn portal_layout_retry_timeout( + pub(in crate::backend::wayland) fn layout_retry_timeout( &self, now: Instant, ) -> Option { @@ -52,34 +61,17 @@ impl FrozenState { } /// Called only after the runtime refreshes all output geometry. - pub(in crate::backend::wayland) fn restart_portal_preflight( + pub(in crate::backend::wayland) fn restart_preflight( &mut self, now: Instant, ) -> Result { - let Some(output_id) = self - .layout_retry - .take_ready( - self.active_output_id, - self.layout_generations.desktop, - self.active_geometry.as_ref(), - now, - ) - .map_err(CapturePreflightError::from)? - else { - return Ok(false); - }; - - self.preflight.begin( - FrozenCaptureBackend::Portal, - Some(output_id), - self.layout_generations.desktop, - ); - log::info!( - "portal.freeze phase=retry-preflight output={output_id} layout={}", - self.layout_generations.desktop - ); - - Ok(true) + self.layout_retry.restart_preflight( + &mut self.preflight, + self.active_output_id, + self.layout_generations, + self.active_geometry.as_ref(), + now, + ) } } @@ -156,13 +148,13 @@ mod tests { drain(&mut frozen, &mut input).await; - assert!(frozen.has_portal_layout_retry()); + assert!(frozen.has_layout_retry()); assert!(frozen.has_acquisition_attempt()); assert!(!frozen.take_capture_done()); assert!(frozen.take_acquisition_completion().is_none()); assert!( frozen - .restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .restart_preflight(Instant::now() + Duration::from_millis(150)) .unwrap() ); assert_eq!( @@ -207,7 +199,7 @@ mod tests { } else { matches!(terminal.outcome, ScreenAcquisitionOutcome::Ready { .. }) }); - assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.has_layout_retry()); assert!(!frozen.has_acquisition_attempt()); assert!(frozen.take_capture_done()); assert!(frozen.take_acquisition_completion().is_none()); @@ -252,7 +244,7 @@ mod tests { .unwrap() ); - assert!(frozen.has_portal_layout_retry()); + assert!(frozen.has_layout_retry()); assert!(frozen.has_acquisition_attempt()); assert!(frozen.take_acquisition_completion().is_none()); assert!(!frozen.take_capture_done()); @@ -298,7 +290,7 @@ mod tests { drain(&mut frozen, &mut input).await; - assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.has_layout_retry()); assert!(!frozen.is_in_progress()); let terminal = frozen.take_acquisition_completion().unwrap(); @@ -323,14 +315,14 @@ mod tests { if switch_output { frozen.set_active_output(None, Some(2)); - assert!(frozen.restart_portal_preflight(Instant::now()).is_err()); + assert!(frozen.restart_preflight(Instant::now()).is_err()); } frozen.cancel(&mut input); - assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.has_layout_retry()); assert!(!frozen.is_in_progress()); - assert!(!frozen.restart_portal_preflight(Instant::now()).unwrap()); + assert!(!frozen.restart_preflight(Instant::now()).unwrap()); } } } diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index aad3e991..319fc1f3 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -1,10 +1,11 @@ mod activation; +mod admission; mod direct; pub(super) use direct::{DirectCaptureAttempt, DirectCaptureContext}; use crate::backend::wayland::capture_preflight::{ - CaptureLayoutGenerations, CapturePreflight, CapturePreflightError, PortalLayoutRetry, + CaptureLayoutGenerations, CaptureLayoutRetry, CapturePreflight, CapturePreflightError, }; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -69,7 +70,7 @@ pub struct FrozenState { pub(super) runtime_wake: Option, pub(super) preflight: CapturePreflight, pub(super) capture_done: bool, - pub(super) layout_retry: PortalLayoutRetry, + pub(super) layout_retry: CaptureLayoutRetry, pending_image: Option, acquisition_attempt: Option<(ScreenAcquisitionId, ScreenAcquisitionOwner)>, acquisition_completion: Option, @@ -134,7 +135,7 @@ impl FrozenState { runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, - layout_retry: PortalLayoutRetry::default(), + layout_retry: CaptureLayoutRetry::default(), pending_image: None, acquisition_attempt: None, acquisition_completion: None, @@ -232,43 +233,9 @@ impl FrozenState { || self.layout_retry.is_pending() } - pub(in crate::backend::wayland) fn take_preflight_pending( - &mut self, - ) -> Option { - self.preflight.take_pending() - } - - #[cfg(test)] - pub(super) fn snapshot_preflight_layout(&mut self) { - self.preflight.begin( - FrozenCaptureBackend::Portal, - self.active_output_id, - self.layout_generations.desktop, - ); - } - - pub(super) fn capture_layout_generation(&self, backend: FrozenCaptureBackend) -> u64 { - match backend { - FrozenCaptureBackend::Portal => self.layout_generations.desktop, - FrozenCaptureBackend::WlrScreencopy | FrozenCaptureBackend::ExtImageCopy => { - self.layout_generations.active_output - } - } - } - - pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), CapturePreflightError> { - self.preflight.ensure_layout_current( - self.active_output_id, - self.preflight - .backend() - .map(|backend| self.capture_layout_generation(backend)) - .unwrap_or(self.layout_generations.active_output), - ) - } - #[cfg(test)] - pub fn preflight_layout_is_current(&self) -> bool { - self.ensure_preflight_layout_current().is_ok() + pub(in crate::backend::wayland) fn preflight_pending(&self) -> bool { + self.preflight.is_pending() } pub(super) fn push_stale_layout_toast(input_state: &mut InputState) { @@ -347,7 +314,7 @@ impl FrozenState { } fn finish_attempt_resources(&mut self) { - self.layout_retry = PortalLayoutRetry::default(); + self.layout_retry = CaptureLayoutRetry::default(); if let Some(capture) = self.direct_capture.take() { capture.destroy(); } @@ -1418,7 +1385,7 @@ mod tests { state.finish_preflight_failure(CapturePreflightError::LostSuppression, &mut input_state); - assert!(!state.has_portal_layout_retry()); + assert!(!state.has_layout_retry()); assert!(!state.is_in_progress()); assert!(state.take_capture_done()); assert!(state.take_acquisition_completion().is_none()); diff --git a/src/backend/wayland/frozen/state/admission.rs b/src/backend/wayland/frozen/state/admission.rs new file mode 100644 index 00000000..25b31950 --- /dev/null +++ b/src/backend/wayland/frozen/state/admission.rs @@ -0,0 +1,75 @@ +//! Backend selection and admission of the retained Freeze request. +use super::*; +use crate::backend::wayland::capture_preflight::{CaptureBackend, CaptureLayoutScope}; +use crate::backend::wayland::state::OverlaySuppression; + +impl CaptureBackend for FrozenCaptureBackend { + fn layout_scope(self) -> CaptureLayoutScope { + match self { + Self::Portal => CaptureLayoutScope::Desktop, + Self::WlrScreencopy | Self::ExtImageCopy => CaptureLayoutScope::ActiveOutput, + } + } + + fn suppression_reason(self) -> OverlaySuppression { + OverlaySuppression::Frozen + } +} + +impl FrozenState { + pub(in crate::backend::wayland) fn take_preflight_pending( + &mut self, + ) -> Option { + self.preflight.take_pending() + } + + #[cfg(test)] + pub(in crate::backend::wayland::frozen) fn snapshot_preflight_layout(&mut self) { + self.preflight.begin( + FrozenCaptureBackend::Portal, + self.active_output_id, + self.layout_generations.desktop, + ); + } + + pub(in crate::backend::wayland::frozen) fn capture_layout_generation( + &self, + backend: FrozenCaptureBackend, + ) -> u64 { + backend.layout_generation(self.layout_generations) + } + + pub(in crate::backend::wayland::frozen) fn ensure_preflight_layout_current( + &self, + ) -> Result<(), CapturePreflightError> { + self.preflight.ensure_layout_current( + self.active_output_id, + self.preflight.generation(self.layout_generations), + ) + } + + /// Admission needs a known active output and the layout saved when the + /// request began; an unknown output is retried, never captured. + pub(in crate::backend::wayland::frozen) fn ensure_preflight_admission( + &self, + ) -> Result<(), CapturePreflightError> { + self.preflight.ensure_admission_current( + self.active_output_id, + self.preflight.generation(self.layout_generations), + ) + } + + pub(in crate::backend::wayland) fn log_preflight_layout(&self, barrier_id: Option) { + self.preflight.log_layout( + "snapshot", + barrier_id, + self.active_output_id, + self.layout_generations, + ); + } + + #[cfg(test)] + pub fn preflight_layout_is_current(&self) -> bool { + self.ensure_preflight_layout_current().is_ok() + } +} diff --git a/src/backend/wayland/handlers/compositor.rs b/src/backend/wayland/handlers/compositor.rs index 1fa0ce8c..169a3be6 100644 --- a/src/backend/wayland/handlers/compositor.rs +++ b/src/backend/wayland/handlers/compositor.rs @@ -114,47 +114,20 @@ impl CompositorHandler for WaylandState { return; } - debug!("Surface entered output"); - let previous_output = self.surface.current_output(); - let output_changed = previous_output.as_ref() != Some(output); - self.surface.set_current_output(output.clone()); - if output_changed { - // Keep layer-shell toolbars pinned to the monitor that owns the drawing surface. - self.toolbar_chrome.set_needs_recreate(true); - } - self.refresh_active_output_label(); - - if let Some(info) = self.protocol.output().info(output) { - let scale = info.scale_factor.max(1); - self.surface.set_scale(scale); - // Mark full damage when entering output - scale may have changed, pool may be new - self.buffer_damage - .mark_all_full(FullDamageReason::OutputChanged); - self.toolbar.maybe_update_scale(Some(output), scale); - self.toolbar.mark_dirty(); - } - self.refresh_freeze_zoom_geometry(); - self.frozen.unfreeze(&mut self.input_state); - self.zoom.deactivate(&mut self.input_state); - - // Update frozen buffer dimensions in case this output's scale differs - let (phys_w, phys_h) = self.surface.physical_dimensions(); - self.frozen - .handle_resize(phys_w, phys_h, &mut self.input_state); - self.zoom - .handle_resize(phys_w, phys_h, &mut self.input_state); - self.cancel_screen_modals_if_source_changed(); + self.surface.enter_output(output.clone()); + self.refresh_surface_output(previous_output.as_ref(), None); + self.log_capture_output_event( + super::output::CaptureOutputEvent::SurfaceEnter, + output, + previous_output.as_ref(), + ); // If freeze-on-start was requested, trigger it once the surface is configured and active. if self.frozen.take_pending_on_start() { info!("Applying freeze-on-start after initial configure"); self.input_state.request_frozen_toggle(); } - - let identity = self.output_identity_for(output); - self.begin_session_output_transition(identity, "surface output change"); - self.input_state.needs_redraw = true; } fn surface_leave( @@ -168,14 +141,56 @@ impl CompositorHandler for WaylandState { return; } - debug!("Surface left output"); + let previous_output = self.surface.current_output(); self.surface.clear_output(output); - self.refresh_active_output_label(); - self.frozen.set_active_output(None, None); - self.zoom.set_active_output(None, None); - self.set_freeze_zoom_geometry(None); - self.frozen.unfreeze(&mut self.input_state); - self.zoom.deactivate(&mut self.input_state); - self.cancel_screen_modals_if_source_changed(); + self.refresh_surface_output(previous_output.as_ref(), None); + self.log_capture_output_event( + super::output::CaptureOutputEvent::SurfaceLeave, + output, + previous_output.as_ref(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::frozen::FrozenImage; + use crate::backend::wayland::handlers::test_support::HandlerFixture; + + #[test] + fn reenter_and_neighbour_leave_preserve_installed_freeze_and_zoom() { + let mut fixture = HandlerFixture::with_outputs(crate::config::Config::default(), 2); + let outputs: Vec<_> = fixture.state.protocol.output().outputs().collect(); + let qh = fixture.queue.handle(); + let surface = fixture.state.surface.wl_surface().unwrap().clone(); + let state = &mut fixture.state; + state.surface_enter(&fixture.conn, &qh, &surface, &outputs[0]); + state.frozen.set_image(FrozenImage { + width: 1, + height: 1, + stride: 4, + data: vec![7; 4], + }); + state.input_state.set_frozen_active(true); + state.zoom.activate_without_capture(); + state.toolbar_chrome.set_needs_recreate(false); + + state.surface_enter(&fixture.conn, &qh, &surface, &outputs[0]); + state.surface_enter(&fixture.conn, &qh, &surface, &outputs[1]); + state.surface_leave(&fixture.conn, &qh, &surface, &outputs[1]); + + assert_eq!(state.surface.current_output(), Some(outputs[0].clone())); + assert_eq!(state.frozen.image().unwrap().data, vec![7; 4]); + assert!(state.input_state.frozen_active()); + assert!(state.zoom.active); + assert!(!state.toolbar_chrome.needs_recreate()); + + state.surface_leave(&fixture.conn, &qh, &surface, &outputs[0]); + + assert!(state.surface.current_output().is_none()); + assert!(state.frozen.image().is_none()); + assert!(!state.input_state.frozen_active()); + assert!(!state.zoom.active); } } diff --git a/src/backend/wayland/handlers/output.rs b/src/backend/wayland/handlers/output.rs index a66c5811..77164fab 100644 --- a/src/backend/wayland/handlers/output.rs +++ b/src/backend/wayland/handlers/output.rs @@ -1,10 +1,38 @@ // Tracks monitor hotplug/updates so `WaylandState` can respond to geometry changes. -use log::debug; +use log::{Level, log, log_enabled, trace}; use smithay_client_toolkit::output::{OutputHandler, OutputState}; -use wayland_client::{Connection, QueueHandle, protocol::wl_output}; +use wayland_client::{Connection, Proxy, QueueHandle, protocol::wl_output}; +use super::super::capture_preflight::{CAPTURE_LOG_TARGET, LogField}; use super::super::state::WaylandState; +pub(super) enum CaptureOutputEvent { + New, + Updated, + Destroyed, + SurfaceEnter, + SurfaceLeave, +} + +impl CaptureOutputEvent { + fn phase(&self) -> &'static str { + match self { + Self::New => "new", + Self::Updated => "updated", + Self::Destroyed => "destroyed", + Self::SurfaceEnter => "surface-enter", + Self::SurfaceLeave => "surface-leave", + } + } + + fn level(&self) -> Level { + match self { + Self::SurfaceEnter | Self::SurfaceLeave => Level::Info, + Self::New | Self::Updated | Self::Destroyed => Level::Debug, + } + } +} + impl OutputHandler for WaylandState { fn output_state(&mut self) -> &mut OutputState { self.protocol.output_mut() @@ -14,11 +42,15 @@ impl OutputHandler for WaylandState { &mut self, _conn: &Connection, _qh: &QueueHandle, - _output: wl_output::WlOutput, + output: wl_output::WlOutput, ) { - debug!("New output detected"); self.refresh_active_output_label(); self.refresh_freeze_zoom_geometry(); + self.log_capture_output_event( + CaptureOutputEvent::New, + &output, + self.surface.current_output().as_ref(), + ); self.cancel_screen_modals_if_source_changed(); } @@ -28,7 +60,6 @@ impl OutputHandler for WaylandState { _qh: &QueueHandle, output: wl_output::WlOutput, ) { - debug!("Output updated"); if self.surface.current_output().as_ref() == Some(&output) { self.refresh_active_output_label(); } @@ -36,6 +67,11 @@ impl OutputHandler for WaylandState { // is added, moved, scaled, or given logical geometry still has to // refresh the active crop. self.refresh_freeze_zoom_geometry(); + self.log_capture_output_event( + CaptureOutputEvent::Updated, + &output, + self.surface.current_output().as_ref(), + ); self.cancel_screen_modals_if_source_changed(); } @@ -45,13 +81,59 @@ impl OutputHandler for WaylandState { _qh: &QueueHandle, output: wl_output::WlOutput, ) { - debug!("Output destroyed"); + let previous_output = self.surface.current_output(); self.surface.clear_output(&output); // SCTK 0.20 calls this before removing the output from OutputState, so // a walk of current outputs would still include it. Exclude it here; // there is no later callback after the removal. - self.refresh_active_output_label_excluding(Some(&output)); - self.refresh_freeze_zoom_geometry_excluding(Some(&output)); + self.refresh_surface_output(previous_output.as_ref(), Some(&output)); + self.log_capture_output_event( + CaptureOutputEvent::Destroyed, + &output, + previous_output.as_ref(), + ); self.cancel_screen_modals_if_source_changed(); } } + +impl WaylandState { + pub(in crate::backend::wayland::handlers) fn log_capture_output_event( + &self, + event: CaptureOutputEvent, + output: &wl_output::WlOutput, + previous: Option<&wl_output::WlOutput>, + ) { + let level = event.level(); + if !log_enabled!(target: CAPTURE_LOG_TARGET, level) { + return; + } + + let outputs = self.protocol.output(); + let metadata = outputs.info(output); + let active = self.surface.current_output(); + let active_metadata = active.as_ref().and_then(|output| outputs.info(output)); + let previous_metadata = previous.and_then(|output| outputs.info(output)); + let phase = event.phase(); + + log!(target: CAPTURE_LOG_TARGET, level, + "capture.output phase={phase} output_object={} output_id={} output_name={} previous_output_object={} previous_output_id={} previous_output_name={} active_output_object={} active_output_id={} active_output_name={} desktop_generation={}", + output.id().protocol_id(), + LogField(metadata.as_ref().map(|info| info.id)), + LogField(metadata.as_ref().and_then(|info| info.name.as_deref())), + LogField(previous.map(|output| output.id().protocol_id())), + LogField(previous_metadata.as_ref().map(|info| info.id)), + LogField(previous_metadata.as_ref().and_then(|info| info.name.as_deref())), + LogField(active.as_ref().map(|output| output.id().protocol_id())), + LogField(active_metadata.as_ref().map(|info| info.id)), + LogField(active_metadata.as_ref().and_then(|info| info.name.as_deref())), + self.frozen.desktop_layout_generation() + ); + trace!(target: CAPTURE_LOG_TARGET, + "capture.output phase={phase} output_object={} metadata={metadata:?} surface_size={}x{} surface_scale={}", + output.id().protocol_id(), + self.surface.width(), + self.surface.height(), + self.surface.scale() + ); + } +} diff --git a/src/backend/wayland/handlers/test_support.rs b/src/backend/wayland/handlers/test_support.rs index 9a57933a..111569c8 100644 --- a/src/backend/wayland/handlers/test_support.rs +++ b/src/backend/wayland/handlers/test_support.rs @@ -10,10 +10,14 @@ use smithay_client_toolkit::{ SeatState, pointer_constraints::PointerConstraintsState, relative_pointer::RelativePointerState, }, - shell::xdg::{XdgShell, window::WindowDecorations}, + shell::xdg::{XdgShell, XdgSurface, window::WindowDecorations}, shm::Shm, }; -use wayland_client::{Connection, EventQueue, globals::registry_queue_init}; +use wayland_client::{ + Connection, EventQueue, Proxy, globals::registry_queue_init, protocol::wl_output, +}; +use wayland_protocols::ext::image_copy_capture::v1::client::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1; +use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; use crate::backend::wayland::{ backend::runtime_wake::RuntimeWakeSource, @@ -25,42 +29,73 @@ pub(in crate::backend::wayland) struct HandlerFixture { pub state: WaylandState, pub conn: Connection, pub queue: EventQueue, - _peer: UnixStream, + peer: UnixStream, + direct_capture_manager: Option, _wake: RuntimeWakeSource, _runtime: tokio::runtime::Runtime, _data: crate::test_temp::TempDir, } +#[derive(Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum CaptureFixtureBackend { + Unavailable, + Portal, + ExtImageCopy, + WlrScreencopy, +} + impl HandlerFixture { pub fn new(config: crate::config::Config) -> Self { + Self::new_inner(config, 0, CaptureFixtureBackend::Unavailable) + } + + pub fn with_outputs(config: crate::config::Config, output_count: u32) -> Self { + Self::new_inner(config, output_count, CaptureFixtureBackend::Unavailable) + } + + pub fn with_capture_output( + config: crate::config::Config, + backend: CaptureFixtureBackend, + ) -> Self { + Self::new_inner(config, 1, backend) + } + + fn new_inner( + config: crate::config::Config, + output_count: u32, + backend: CaptureFixtureBackend, + ) -> Self { let (client, mut peer) = UnixStream::pair().unwrap(); peer.set_read_timeout(Some(std::time::Duration::from_secs(5))) .unwrap(); let bootstrap = std::thread::spawn(move || { loop { - let mut header = [0u8; 8]; - peer.read_exact(&mut header).unwrap(); - let object = u32::from_ne_bytes(header[..4].try_into().unwrap()); - let size_opcode = u32::from_ne_bytes(header[4..].try_into().unwrap()); - let mut payload = vec![0; (size_opcode >> 16) as usize - 8]; - peer.read_exact(&mut payload).unwrap(); - assert_eq!(object, 1); - let id = u32::from_ne_bytes(payload[..4].try_into().unwrap()); - match size_opcode & 0xffff { + let request = read_request(&mut peer); + assert_eq!(request.object, 1); + let id = request.first_word(); + match request.opcode { 1 => { - for (name, interface, version) in [ + let mut advertised = vec![ (1, "wl_compositor", 6), (2, "wl_shm", 1), (3, "xdg_wm_base", 1), - ] { + ]; + for index in 0..output_count { + advertised.push((4 + index, "wl_output", 4)); + } + + if backend == CaptureFixtureBackend::ExtImageCopy { + advertised.push((20, "ext_image_copy_capture_manager_v1", 1)); + advertised.push((21, "ext_output_image_capture_source_manager_v1", 1)); + } + if backend == CaptureFixtureBackend::WlrScreencopy { + advertised.push((22, "zwlr_screencopy_manager_v1", 3)); + } + + for (name, interface, version) in advertised { let mut body = Vec::new(); body.extend_from_slice(&u32::to_ne_bytes(name)); - body.extend_from_slice(&((interface.len() + 1) as u32).to_ne_bytes()); - body.extend_from_slice(interface.as_bytes()); - body.push(0); - while body.len() % 4 != 0 { - body.push(0); - } + append_string(&mut body, interface); body.extend_from_slice(&u32::to_ne_bytes(version)); send(&mut peer, id, 0, &body); } @@ -103,6 +138,29 @@ impl HandlerFixture { let palette_recents = crate::palette_recents::PaletteRecentsStore::load_from_path( data.path().join("palette_recents.toml"), ); + + let screencopy_manager: Option = (backend + == CaptureFixtureBackend::WlrScreencopy) + .then(|| globals.bind(&qh, 1..=3, ()).unwrap()); + let ext_capture_manager: Option = (backend + == CaptureFixtureBackend::ExtImageCopy) + .then(|| globals.bind(&qh, 1..=1, ()).unwrap()); + let direct_capture_manager = screencopy_manager + .as_ref() + .map(|manager| manager.id().protocol_id()) + .or_else(|| { + ext_capture_manager + .as_ref() + .map(|manager| manager.id().protocol_id()) + }); + let ext_image_copy_managers = ext_capture_manager.map(|capture| { + let source = globals.bind(&qh, 1..=1, ()).unwrap(); + + crate::backend::wayland::frozen::ExtImageCopyManagers::new(capture, source) + }); + + // Placement follows the configured preference, as at startup without env overrides. + let xdg_fullscreen = config.ui.xdg_fullscreen; let mut state = WaylandState::new(WaylandStateInit { ui_text: crate::ui_text::UiTextEngine::default(), text_measurer: crate::draw::TextMeasurer::default(), @@ -130,14 +188,14 @@ impl HandlerFixture { runtime_wake: wake.handle(), tokio_handle: runtime.handle().clone(), exit_after_capture_mode: crate::backend::ExitAfterCaptureMode::Never, - frozen_enabled: false, + frozen_enabled: backend != CaptureFixtureBackend::Unavailable, preferred_output_identity: None, - xdg_fullscreen: false, + xdg_fullscreen, main_surface_uses_overlay_layer: false, pending_freeze_on_start: false, - screencopy_manager: None, - ext_image_copy_managers: None, - portal_freeze_supported: false, + screencopy_manager, + ext_image_copy_managers, + portal_freeze_supported: backend == CaptureFixtureBackend::Portal, text_input_manager: None, #[cfg(feature = "tablet-input")] tablet_manager: None, @@ -156,12 +214,154 @@ impl HandlerFixture { state, conn, queue, - _peer: peer, + peer, + direct_capture_manager, _wake: wake, _runtime: runtime, _data: data, } } + + /// A direct fixture must receive a real request on its advertised manager. + /// No portal is available in these cases; a portal task cannot satisfy this proof. + pub fn assert_direct_capture_started(&mut self) { + let Some(manager) = self.direct_capture_manager else { + return; + }; + + self.conn.flush().unwrap(); + while read_request(&mut self.peer).object != manager {} + } + + /// Deliver a compositor configure for the fixture's xdg window. + pub fn configure_xdg_window(&mut self) { + let window = self.state.surface.xdg_window().unwrap().clone(); + let mut toplevel = Vec::new(); + for value in [0i32, 0] { + toplevel.extend_from_slice(&value.to_ne_bytes()); + } + // An empty states array. + toplevel.extend_from_slice(&0u32.to_ne_bytes()); + + send( + &mut self.peer, + window.xdg_toplevel().id().protocol_id(), + 0, + &toplevel, + ); + send( + &mut self.peer, + window.xdg_surface().id().protocol_id(), + 0, + &1u32.to_ne_bytes(), + ); + self.dispatch_peer_events(); + } + + fn dispatch_peer_events(&mut self) { + self.queue.prepare_read().unwrap().read().unwrap(); + self.queue.dispatch_pending(&mut self.state).unwrap(); + } + + /// Deliver real output metadata before its surface-enter notification. + pub fn complete_output_metadata(&mut self, name: &str) -> wl_output::WlOutput { + let output = self.state.protocol.output().outputs().next().unwrap(); + self.complete_output_metadata_for(output, name) + } + + pub fn complete_output_metadata_for( + &mut self, + output: wl_output::WlOutput, + name: &str, + ) -> wl_output::WlOutput { + let object = output.id().protocol_id(); + let mut geometry = Vec::new(); + for value in [0i32, 0, 300, 200, 0] { + geometry.extend_from_slice(&value.to_ne_bytes()); + } + append_string(&mut geometry, "Test"); + append_string(&mut geometry, "Output"); + geometry.extend_from_slice(&0i32.to_ne_bytes()); + send(&mut self.peer, object, 0, &geometry); + + let mut mode = Vec::new(); + for value in [3u32, 1000, 800, 60_000] { + mode.extend_from_slice(&value.to_ne_bytes()); + } + send(&mut self.peer, object, 1, &mode); + send(&mut self.peer, object, 3, &1i32.to_ne_bytes()); + let mut output_name = Vec::new(); + append_string(&mut output_name, name); + send(&mut self.peer, object, 4, &output_name); + send(&mut self.peer, object, 2, &[]); + + self.dispatch_peer_events(); + assert_eq!( + self.state + .protocol + .output() + .info(&output) + .unwrap() + .name + .as_deref(), + Some(name) + ); + + output + } + + /// Complete the callback requested by the real hidden-frame render path. + /// This peer never connects to the user's compositor. + pub fn complete_main_frame(&mut self) { + self.conn.flush().unwrap(); + let surface = self.state.surface.wl_surface().unwrap().id().protocol_id(); + let callback = loop { + let request = read_request(&mut self.peer); + if request.object == surface && request.opcode == 3 { + break request.first_word(); + } + }; + + send(&mut self.peer, callback, 0, &0u32.to_ne_bytes()); + self.dispatch_peer_events(); + } +} + +struct PeerRequest { + object: u32, + opcode: u32, + payload: Vec, +} + +impl PeerRequest { + fn first_word(&self) -> u32 { + u32::from_ne_bytes(self.payload[..4].try_into().unwrap()) + } +} + +fn read_request(peer: &mut UnixStream) -> PeerRequest { + let mut header = [0u8; 8]; + peer.read_exact(&mut header) + .expect("the client did not send the expected request"); + let object = u32::from_ne_bytes(header[..4].try_into().unwrap()); + let size_opcode = u32::from_ne_bytes(header[4..].try_into().unwrap()); + let mut payload = vec![0; (size_opcode >> 16) as usize - 8]; + peer.read_exact(&mut payload).unwrap(); + + PeerRequest { + object, + opcode: size_opcode & 0xffff, + payload, + } +} + +fn append_string(body: &mut Vec, value: &str) { + body.extend_from_slice(&((value.len() + 1) as u32).to_ne_bytes()); + body.extend_from_slice(value.as_bytes()); + body.push(0); + while !body.len().is_multiple_of(4) { + body.push(0); + } } fn send(peer: &mut UnixStream, object: u32, opcode: u32, payload: &[u8]) { diff --git a/src/backend/wayland/handlers/xdg.rs b/src/backend/wayland/handlers/xdg.rs index 6a11769c..6c5ccbf1 100644 --- a/src/backend/wayland/handlers/xdg.rs +++ b/src/backend/wayland/handlers/xdg.rs @@ -89,7 +89,7 @@ impl WindowHandler for WaylandState { if self.surface.current_output().is_none() && let Some(output) = self.protocol.output().outputs().next() { - self.surface.set_current_output(output); + self.surface.assume_output(output); } self.refresh_active_output_label(); @@ -145,3 +145,36 @@ impl WindowHandler for WaylandState { self.begin_configure_fallback_session_transition("xdg configure fallback"); } } + +#[cfg(test)] +mod tests { + use smithay_client_toolkit::compositor::CompositorHandler; + + use crate::backend::wayland::handlers::test_support::HandlerFixture; + + #[test] + fn configure_output_guess_yields_to_the_compositor_enter() { + let mut fixture = HandlerFixture::with_outputs(crate::config::Config::default(), 2); + let outputs: Vec<_> = fixture.state.protocol.output().outputs().collect(); + for (output, name) in outputs.iter().zip(["DP-1", "DP-2"]) { + fixture.complete_output_metadata_for(output.clone(), name); + } + let qh = fixture.queue.handle(); + let surface = fixture.state.surface.wl_surface().unwrap().clone(); + + fixture.configure_xdg_window(); + assert_eq!( + fixture.state.surface.current_output(), + Some(outputs[0].clone()) + ); + + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &outputs[1]); + + assert_eq!( + fixture.state.surface.current_output(), + Some(outputs[1].clone()) + ); + } +} diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index f0890bda..93af66d6 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -7,8 +7,8 @@ ## Architecture - This subtree supports live overlay runtime state: buffers, damage, boards, capture routing, clipboard paste, color picker, onboarding, PDF export, render helpers, toolbar plumbing, zoom, and core accessors. - Runtime owners extracted from `WaylandState` live beside it: `focus.rs` (activation, focus, and startup acquisition), `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer position, board-pan and chrome gestures, cursor, pointer-lock, and touch lifecycles), `region_capture/runtime.rs` (region generations, active/review/window-snap state, and query/preview workers), `acquisition.rs` (screen acquisition, zoom waiters, and eyedropper source correlation), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `preference_stores.rs` (persistence stores and workers), `ui_animation.rs` (animation scheduling), `font_catalog.rs` (font-catalog prewarm), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). -- `capture/barrier.rs` owns GTK/main-surface presentation proof and checks available swapchain slots before a render wake. `capture/retry.rs` coordinates settled portal re-admission while retaining suppression and domain-specific completion ownership. `../backend/event_loop/` computes the frame-cap delay shared by normal rendering and capture wakes. -- `core/overlay.rs` owns suppression policy and capture-barrier state; `../surface.rs` owns output/fullscreen/layer placement and frozen-fullscreen transitions. +- `capture/barrier.rs` owns GTK/main-surface presentation proof and checks available swapchain slots before a render wake. `capture/retry.rs` coordinates settled re-admission on the selected backend while retaining suppression, keyboard policy, and domain-specific completion ownership. `../backend/event_loop/` computes the frame-cap delay shared by normal rendering and capture wakes. +- `core/overlay.rs` owns suppression policy and capture-barrier state; `../surface.rs` owns the main overlay surface, with output selection in `../surface/output_membership.rs` and layer/frozen-fullscreen placement in `../surface/placement.rs`. - `render/` separates mutable preparation, pure `FramePlan` decisions, Cairo painting, and buffer submission. `CanvasRenderCtx` borrows frame parameters; `RenderRuntime` owns the canvas layer cache, explicit drawing caches, resolved theme, UI paint caches, profile baseline, and per-effect damage history. Direct canvas rendering, layer baking, previews, and board thumbnails borrow that drawing owner in short scopes. `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. ## Invariants diff --git a/src/backend/wayland/state/capture/backdrop.rs b/src/backend/wayland/state/capture/backdrop.rs index 7cdcddee..a1ab806c 100644 --- a/src/backend/wayland/state/capture/backdrop.rs +++ b/src/backend/wayland/state/capture/backdrop.rs @@ -116,13 +116,6 @@ impl WaylandState { Some(count) } - pub(in crate::backend::wayland) fn set_freeze_zoom_geometry( - &mut self, - geometry: Option, - ) { - self.set_freeze_zoom_geometry_excluding(geometry, None); - } - pub(in crate::backend::wayland) fn set_freeze_zoom_geometry_excluding( &mut self, geometry: Option, diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index f75fce40..9b274338 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -83,6 +83,11 @@ impl OverlayCaptureBarrier { gtk_paint_generation } + /// Identifies the active barrier in capture diagnostics. + fn active_generation(&self) -> Option { + self.active.map(|active| active.generation) + } + pub(in crate::backend::wayland::state) fn gtk_paint_generation(&self) -> Option { self.active.and_then(|active| active.gtk_paint_generation) } @@ -349,6 +354,7 @@ impl WaylandState { } fn begin_ready_overlay_capture(&mut self, qh: &QueueHandle) { + let barrier_id = self.suppression.barrier.active_generation(); let Some(reason) = self.suppression.barrier.take_ready() else { return; }; @@ -363,6 +369,7 @@ impl WaylandState { match reason { OverlaySuppression::Frozen => { + self.frozen.log_preflight_layout(barrier_id); let Some(backend) = self.frozen.take_preflight_pending() else { log::warn!("Frozen capture barrier completed without a pending preflight"); self.cancel_overlay_capture_preflight(reason, None); @@ -374,10 +381,11 @@ impl WaylandState { qh, &self.tokio_handle, ) { - log::warn!("Frozen preflight capture failed: {err}"); - if self.frozen.retry_stale_portal_preflight(backend) { + if self.frozen.retry_stale_preflight() { + log::info!("Frozen preflight capture deferred to its retry: {err}"); return; } + log::warn!("Frozen preflight capture failed: {err}"); if self.frozen.has_acquisition_attempt() { self.frozen.finish_preflight_failure( CapturePreflightError::from_backend(err), @@ -394,6 +402,7 @@ impl WaylandState { } } OverlaySuppression::Zoom => { + self.zoom.log_preflight_layout(barrier_id); let Some(backend) = self.zoom.take_preflight_pending() else { log::warn!("Zoom capture barrier completed without a pending preflight"); self.cancel_overlay_capture_preflight(reason, None); @@ -405,10 +414,11 @@ impl WaylandState { qh, &self.tokio_handle, ) { - log::warn!("Zoom preflight capture failed: {err}"); - if self.zoom.retry_stale_portal_preflight(backend) { + if self.zoom.retry_stale_preflight() { + log::info!("Zoom preflight capture deferred to its retry: {err}"); return; } + log::warn!("Zoom preflight capture failed: {err}"); self.zoom.finish_preflight_failure( &mut self.input_state, CapturePreflightError::from_backend(err), diff --git a/src/backend/wayland/state/capture/retry.rs b/src/backend/wayland/state/capture/retry.rs index 10bcda68..08997308 100644 --- a/src/backend/wayland/state/capture/retry.rs +++ b/src/backend/wayland/state/capture/retry.rs @@ -1,17 +1,17 @@ -//! Re-admission of portal requests after a bounded layout settling period. +//! Re-admission on the retained backend after a bounded layout settling period. use super::super::core::overlay::OverlaySuppressionState; use super::super::*; use crate::backend::wayland::capture_preflight::CapturePreflightError; use std::time::Instant; impl WaylandState { - pub(in crate::backend::wayland) fn poll_portal_layout_retries(&mut self, now: Instant) { - if !self.frozen.has_portal_layout_retry() && !self.zoom.has_portal_layout_retry() { + pub(in crate::backend::wayland) fn poll_layout_retries(&mut self, now: Instant) { + if !self.frozen.has_layout_retry() && !self.zoom.has_layout_retry() { return; } self.refresh_freeze_zoom_geometry(); - if advance_portal_layout_retries( + if advance_layout_retries( &mut self.frozen, &mut self.zoom, &mut self.suppression, @@ -28,7 +28,7 @@ impl WaylandState { /// Keep eligibility, domain terminals and barrier admission together. The /// runtime publishes redraw damage and GTK updates before it renders again. -fn advance_portal_layout_retries( +fn advance_layout_retries( frozen: &mut FrozenState, zoom: &mut ZoomState, suppression: &mut OverlaySuppressionState, @@ -38,31 +38,28 @@ fn advance_portal_layout_retries( ) -> bool { let mut restarted = false; - if frozen.has_portal_layout_retry() { - match restart_suppressed_portal_retry( + if frozen.has_layout_retry() { + match restart_suppressed_retry( suppression, OverlaySuppression::Frozen, wait_for_gtk, - || frozen.restart_portal_preflight(now), + || frozen.restart_preflight(now), ) { Ok(ready) => restarted |= ready, Err(error) => { - log::warn!("Portal Freeze retry preflight failed: {error}"); + log::warn!("Freeze retry preflight failed: {error}"); frozen.finish_preflight_failure(error, input); } } } - if zoom.has_portal_layout_retry() { - match restart_suppressed_portal_retry( - suppression, - OverlaySuppression::Zoom, - wait_for_gtk, - || zoom.restart_portal_preflight(now), - ) { + if zoom.has_layout_retry() { + match restart_suppressed_retry(suppression, OverlaySuppression::Zoom, wait_for_gtk, || { + zoom.restart_preflight(now) + }) { Ok(ready) => restarted |= ready, Err(error) => { - log::warn!("Portal Zoom retry preflight failed: {error}"); + log::warn!("Zoom retry preflight failed: {error}"); zoom.finish_preflight_failure(input, error); } } @@ -72,7 +69,7 @@ fn advance_portal_layout_retries( restarted } -fn restart_suppressed_portal_retry( +fn restart_suppressed_retry( suppression: &mut OverlaySuppressionState, reason: OverlaySuppression, wait_for_gtk: bool, @@ -93,13 +90,199 @@ fn restart_suppressed_portal_retry( mod tests { use super::*; use crate::backend::wayland::acquisition::{ - ScreenAcquisitionOutcome, ScreenAcquisitionOwner, ScreenAcquisitionRegistry, + ScreenAcquisitionId, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, }; - use crate::backend::wayland::frozen::FrozenCaptureBackend; use crate::backend::wayland::frozen_geometry::OutputGeometry; - use crate::backend::wayland::zoom::{ZoomCaptureBackend, ZoomSourceOutcome}; + use crate::backend::wayland::state::acquisition::AcquisitionRuntime; + use crate::backend::wayland::zoom::{ZoomCaptureBackend, ZoomCaptureId, ZoomSourceOutcome}; use std::time::Duration; + #[derive(Clone, Copy)] + enum RetainedRequest { + Freeze { + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + }, + Zoom(ZoomCaptureId), + } + + impl RetainedRequest { + fn is_freeze(self) -> bool { + matches!(self, Self::Freeze { .. }) + } + + fn assert_terminal(self, frozen: &mut FrozenState, zoom: &mut ZoomState, stale: bool) { + match self { + Self::Freeze { id, owner } => { + let terminal = frozen.take_acquisition_completion().unwrap(); + assert_eq!((terminal.id, terminal.owner), (id, owner)); + assert_eq!( + terminal.outcome == ScreenAcquisitionOutcome::StaleLayout, + stale + ); + if !stale { + assert!(matches!( + terminal.outcome, + ScreenAcquisitionOutcome::Failed(_) + )); + } + + assert!(frozen.take_acquisition_completion().is_none()); + assert!(frozen.take_capture_done()); + assert!(!frozen.is_in_progress()); + } + Self::Zoom(id) => { + let terminal = zoom.take_source_terminal().unwrap(); + assert_eq!(terminal.id, id); + assert_eq!(terminal.outcome == ZoomSourceOutcome::StaleLayout, stale); + if !stale { + assert!(matches!(terminal.outcome, ZoomSourceOutcome::Failed(_))); + } + + assert!(zoom.take_source_terminal().is_none()); + assert!(zoom.take_capture_done()); + assert!(!zoom.is_in_progress()); + } + } + } + } + + fn start_request( + reason: OverlaySuppression, + frozen: &mut FrozenState, + zoom: &mut ZoomState, + registry: &mut AcquisitionRuntime, + owner: ScreenAcquisitionOwner, + ) -> RetainedRequest { + match reason { + OverlaySuppression::Frozen => { + let id = registry.request(owner).unwrap(); + frozen.start_capture_for(id, owner).unwrap(); + registry.mark_started(id, owner); + + RetainedRequest::Freeze { id, owner } + } + OverlaySuppression::Zoom => { + zoom.start_capture(ZoomCaptureBackend::Portal).unwrap(); + zoom.request_activation(); + + RetainedRequest::Zoom(zoom.current_capture_id().unwrap()) + } + _ => panic!("unexpected capture suppression"), + } + } + + #[test] + fn output_discovery_during_preflight_retains_the_request_and_repeats_suppression() { + use crate::backend::wayland::handlers::test_support::{ + CaptureFixtureBackend, HandlerFixture, + }; + use smithay_client_toolkit::compositor::CompositorHandler; + + for reason in [OverlaySuppression::Frozen, OverlaySuppression::Zoom] { + let mut fixture = HandlerFixture::with_capture_output( + crate::config::Config::default(), + CaptureFixtureBackend::Portal, + ); + let output = fixture.complete_output_metadata("DP-3"); + let state = &mut fixture.state; + assert!(state.surface.current_output().is_none()); + + let request = start_request( + reason, + &mut state.frozen, + &mut state.zoom, + &mut state.acquisition, + ScreenAcquisitionOwner::UserFreeze, + ); + state + .suppression + .enter(reason, OverlaySuppressionKeyboardPolicy::Release, true) + .unwrap(); + let first_gtk = state.suppression.barrier.gtk_paint_generation().unwrap(); + let qh = fixture.queue.handle(); + let surface = state.surface.wl_surface().unwrap().clone(); + + state.surface_enter(&fixture.conn, &qh, &surface, &output); + assert_preflight_request_retained(state, request); + + state.acknowledge_gtk_capture_suppression(first_gtk); + assert_eq!( + state.suppression.barrier.begin_main_surface_submission(), + Some(first_gtk) + ); + state.mark_overlay_capture_frame_ready(first_gtk, &qh); + + assert_eq!(state.frozen.has_layout_retry(), request.is_freeze()); + assert_eq!(state.zoom.has_layout_retry(), !request.is_freeze()); + assert_preflight_request_retained(state, request); + + state.surface_enter(&fixture.conn, &qh, &surface, &output); + assert_preflight_request_retained(state, request); + + let now = Instant::now(); + assert!(!advance_layout_retries( + &mut state.frozen, + &mut state.zoom, + &mut state.suppression, + &mut state.input_state, + true, + now, + )); + assert!(advance_layout_retries( + &mut state.frozen, + &mut state.zoom, + &mut state.suppression, + &mut state.input_state, + true, + now + Duration::from_millis(150), + )); + + assert_eq!(state.suppression.reason(), reason); + let retry_gtk = state.suppression.barrier.gtk_paint_generation().unwrap(); + assert_ne!(retry_gtk, first_gtk); + + state.acknowledge_gtk_capture_suppression(first_gtk); + assert_eq!( + state.suppression.barrier.begin_main_surface_submission(), + None + ); + + state.acknowledge_gtk_capture_suppression(retry_gtk); + assert_eq!( + state.suppression.barrier.begin_main_surface_submission(), + Some(retry_gtk) + ); + state.mark_overlay_capture_frame_ready(retry_gtk, &qh); + assert_preflight_request_retained(state, request); + assert!(!state.frozen.preflight_pending()); + assert!(!state.zoom.preflight_pending()); + + state.frozen.cancel(&mut state.input_state); + state.zoom.abort_capture(); + } + } + + fn assert_preflight_request_retained(state: &WaylandState, request: RetainedRequest) { + let freezing = request.is_freeze(); + assert_eq!(state.frozen.has_acquisition_attempt(), freezing); + assert_eq!(state.frozen.is_in_progress(), freezing); + assert_eq!(state.acquisition.slot().is_some(), freezing); + assert_eq!(state.zoom.is_engaged(), !freezing); + assert_eq!(state.zoom.is_in_progress(), !freezing); + + match request { + RetainedRequest::Freeze { id, owner } => { + assert_eq!( + state.acquisition.slot().map(|slot| (slot.id, slot.owner)), + Some((id, owner)) + ); + assert!(state.zoom.current_capture_id().is_none()); + } + RetainedRequest::Zoom(id) => assert_eq!(state.zoom.current_capture_id(), Some(id)), + } + } + #[tokio::test] async fn retry_coordinator_preserves_suppression_and_finishes_failure_once() { for reason in [OverlaySuppression::Frozen, OverlaySuppression::Zoom] { @@ -125,26 +308,24 @@ mod tests { zoom.set_active_output(None, Some(1)); zoom.set_active_geometry(Some(geometry.clone())); - let mut registry = ScreenAcquisitionRegistry::default(); - let owner = ScreenAcquisitionOwner::Ocr; - let id = registry.request(owner).unwrap(); - let mut zoom_id = None; + let mut registry = AcquisitionRuntime::default(); + let request = start_request( + reason, + &mut frozen, + &mut zoom, + &mut registry, + ScreenAcquisitionOwner::Ocr, + ); + frozen.take_preflight_pending(); + zoom.take_preflight_pending(); let mut changed = geometry; changed.logical_x = 8; - if reason == OverlaySuppression::Frozen { - frozen.start_capture_for(id, owner).unwrap(); - frozen.take_preflight_pending(); - frozen.set_active_geometry(Some(changed)); - assert!(frozen.retry_stale_portal_preflight(FrozenCaptureBackend::Portal)); - } else { - zoom.start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) - .unwrap(); - zoom_id = zoom.current_capture_id(); - zoom.take_preflight_pending(); - zoom.set_active_geometry(Some(changed)); - assert!(zoom.retry_stale_portal_preflight(ZoomCaptureBackend::Portal)); - } + frozen.set_active_geometry(Some(changed.clone())); + zoom.set_active_geometry(Some(changed)); + + assert_eq!(frozen.retry_stale_preflight(), request.is_freeze()); + assert_eq!(zoom.retry_stale_preflight(), !request.is_freeze()); let mut suppression = OverlaySuppressionState::default(); let active_reason = if failure == Some("suppression") { @@ -155,7 +336,7 @@ mod tests { suppression .enter( active_reason, - OverlaySuppressionKeyboardPolicy::Release, + OverlaySuppressionKeyboardPolicy::Retain, true, ) .unwrap(); @@ -173,7 +354,7 @@ mod tests { let now = Instant::now(); if failure.is_none() { - assert!(!advance_portal_layout_retries( + assert!(!advance_layout_retries( &mut frozen, &mut zoom, &mut suppression, @@ -184,7 +365,7 @@ mod tests { assert_eq!(suppression.barrier.gtk_paint_generation(), first_gtk); } - let admitted = advance_portal_layout_retries( + let admitted = advance_layout_retries( &mut frozen, &mut zoom, &mut suppression, @@ -198,10 +379,12 @@ mod tests { ); assert_eq!(suppression.reason(), active_reason); - assert!(!frozen.has_portal_layout_retry()); - assert!(!zoom.has_portal_layout_retry()); + assert!(!frozen.has_layout_retry()); + assert!(!zoom.has_layout_retry()); + if failure.is_none() { assert!(admitted); + assert!(!suppression.keyboard_passthrough_requested(false)); assert_ne!(suppression.barrier.gtk_paint_generation(), first_gtk); assert!(input.needs_redraw); assert!(frozen.take_acquisition_completion().is_none()); @@ -209,32 +392,11 @@ mod tests { } else { assert!(!admitted); assert_eq!(suppression.barrier.gtk_paint_generation(), first_gtk); - if reason == OverlaySuppression::Frozen { - let terminal = frozen.take_acquisition_completion().unwrap(); - assert_eq!((terminal.id, terminal.owner), (id, owner)); - if matches!(failure, Some("output" | "metadata")) { - assert_eq!(terminal.outcome, ScreenAcquisitionOutcome::StaleLayout); - } else { - assert!(matches!( - terminal.outcome, - ScreenAcquisitionOutcome::Failed(_) - )); - } - assert!(frozen.take_acquisition_completion().is_none()); - assert!(frozen.take_capture_done()); - assert!(!frozen.is_in_progress()); - } else { - let terminal = zoom.take_source_terminal().unwrap(); - assert_eq!(terminal.id, zoom_id.unwrap()); - if matches!(failure, Some("output" | "metadata")) { - assert_eq!(terminal.outcome, ZoomSourceOutcome::StaleLayout); - } else { - assert!(matches!(terminal.outcome, ZoomSourceOutcome::Failed(_))); - } - assert!(zoom.take_source_terminal().is_none()); - assert!(zoom.take_capture_done()); - assert!(!zoom.is_in_progress()); - } + request.assert_terminal( + &mut frozen, + &mut zoom, + matches!(failure, Some("output" | "metadata")), + ); } } } diff --git a/src/backend/wayland/state/core/output.rs b/src/backend/wayland/state/core/output.rs index 3d39a105..969ac84d 100644 --- a/src/backend/wayland/state/core/output.rs +++ b/src/backend/wayland/state/core/output.rs @@ -16,6 +16,55 @@ mod transition; const OUTPUT_BADGE_MAX_LEN: usize = 28; +impl WaylandState { + /// Publish compositor membership changes once, using the selected output's scale. + pub(in crate::backend::wayland) fn refresh_surface_output( + &mut self, + previous: Option<&wl_output::WlOutput>, + exclude: Option<&wl_output::WlOutput>, + ) { + let active = self.surface.current_output(); + let changed = previous != active.as_ref(); + if changed { + if active.is_some() { + self.toolbar_chrome.set_needs_recreate(true); + } + if let Some(info) = active + .as_ref() + .and_then(|output| self.protocol.output().info(output)) + { + let scale = info.scale_factor.max(1); + self.surface.set_scale(scale); + self.toolbar.maybe_update_scale(active.as_ref(), scale); + } + self.buffer_damage + .mark_all_full(FullDamageReason::OutputChanged); + self.toolbar.mark_dirty(); + } + + self.refresh_active_output_label_excluding(exclude); + self.refresh_freeze_zoom_geometry_excluding(exclude); + + if changed { + self.frozen.unfreeze(&mut self.input_state); + self.zoom.handle_output_change(&mut self.input_state); + let (width, height) = self.surface.physical_dimensions(); + self.frozen + .handle_resize(width, height, &mut self.input_state); + self.zoom + .handle_resize(width, height, &mut self.input_state); + + if let Some(output) = active.as_ref() { + let identity = self.output_identity_for(output); + self.begin_session_output_transition(identity, "surface output change"); + } + self.input_state.needs_redraw = true; + } + + self.cancel_screen_modals_if_source_changed(); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OutputTransitionStart { IgnoreCurrentTarget, diff --git a/src/backend/wayland/state/core/output/focus.rs b/src/backend/wayland/state/core/output/focus.rs index 1c27e4d6..9779d90f 100644 --- a/src/backend/wayland/state/core/output/focus.rs +++ b/src/backend/wayland/state/core/output/focus.rs @@ -63,7 +63,6 @@ impl WaylandState { let target_label = self .output_badge_label_for(&target_output) .unwrap_or_else(|| format!("Output {}", target_index + 1)); - let target_identity = self.output_identity_for(&target_output); if self.surface.is_xdg_window() { if !self.surface.placement().xdg_fullscreen() { @@ -82,9 +81,8 @@ impl WaylandState { info!("Switching xdg overlay to {}", target_label); window.set_fullscreen(Some(&target_output)); window.commit(); - self.surface.set_current_output(target_output); - self.refresh_active_output_label(); - self.begin_session_output_transition(target_identity, "output switch"); + self.surface.request_output(target_output); + self.refresh_surface_output(current_output.as_ref(), None); self.request_xdg_activation(qh); self.input_state.needs_redraw = true; return; @@ -99,9 +97,7 @@ impl WaylandState { info!("Switching layer overlay to {}", target_label); self.teardown_keyboard_focus(); self.recreate_layer_surface_for_output(qh, &target_output); - self.surface.set_current_output(target_output); - self.refresh_active_output_label(); - self.begin_session_output_transition(target_identity, "output switch"); + self.refresh_surface_output(current_output.as_ref(), None); self.input_state.needs_redraw = true; self.sync_toolbar_visibility(qh); } @@ -134,7 +130,8 @@ impl WaylandState { layer_surface.set_exclusive_zone(-1); layer_surface.commit(); - self.surface.set_layer_surface(layer_surface); + self.surface + .set_layer_surface(layer_surface, Some(output.clone())); self.focus .set_keyboard_interactivity(Some(desired_keyboard_mode)); self.force_sync_overlay_interactivity(); @@ -143,3 +140,72 @@ impl WaylandState { self.toolbar_chrome.set_needs_recreate(true); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::frozen::FrozenImage; + use crate::backend::wayland::handlers::test_support::HandlerFixture; + use smithay_client_toolkit::compositor::CompositorHandler; + use smithay_client_toolkit::output::OutputHandler; + + #[test] + fn explicit_output_switch_invalidates_inactive_zoom_cache_before_same_output_enter() { + let mut config = crate::config::Config::default(); + config.ui.xdg_fullscreen = true; + config.ui.multi_monitor_enabled = true; + let mut fixture = HandlerFixture::with_outputs(config, 3); + let outputs: Vec<_> = fixture.state.protocol.output().outputs().collect(); + for (output, name) in outputs.iter().zip(["DP-1", "DP-2", "DP-3"]) { + fixture.complete_output_metadata_for(output.clone(), name); + } + + let qh = fixture.queue.handle(); + let surface = fixture.state.surface.wl_surface().unwrap().clone(); + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &outputs[0]); + fixture.state.zoom.set_image(FrozenImage { + width: 1000, + height: 800, + stride: 4000, + data: vec![7; 3_200_000], + }); + assert!(!fixture.state.zoom.active); + + fixture + .state + .handle_output_focus_action(&qh, OutputFocusAction::Next); + assert_eq!( + fixture.state.surface.current_output(), + Some(outputs[1].clone()) + ); + assert!(fixture.state.zoom.image().is_none()); + + // Old-output events must not override the explicit fullscreen target. + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &outputs[0]); + assert_eq!( + fixture.state.surface.current_output(), + Some(outputs[1].clone()) + ); + fixture + .state + .output_destroyed(&fixture.conn, &qh, outputs[2].clone()); + assert_eq!( + fixture.state.surface.current_output(), + Some(outputs[1].clone()) + ); + + fixture + .state + .surface_enter(&fixture.conn, &qh, &surface, &outputs[1]); + + assert!(fixture.state.zoom.image().is_none()); + assert_eq!( + fixture.state.surface.current_output(), + Some(outputs[1].clone()) + ); + } +} diff --git a/src/backend/wayland/surface.rs b/src/backend/wayland/surface.rs index 2549f73a..12c2405f 100644 --- a/src/backend/wayland/surface.rs +++ b/src/backend/wayland/surface.rs @@ -4,8 +4,6 @@ //! pool. WaylandState asks SurfaceState for buffers and size information //! instead of juggling the raw objects directly. -use std::time::{Duration, Instant}; - use anyhow::{Context, Result}; use log::info; use smithay_client_toolkit::{ @@ -20,107 +18,11 @@ use wayland_client::{ protocol::{wl_output, wl_shm, wl_surface}, }; -const XDG_FROZEN_FULLSCREEN_TIMEOUT: Duration = Duration::from_millis(1500); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum XdgFrozenFullscreenState { - #[default] - Inactive, - PendingConfigure, - Active, -} - -#[derive(Debug, Default)] -pub(in crate::backend::wayland) struct XdgFrozenFullscreen { - state: XdgFrozenFullscreenState, - requested_at: Option, -} - -impl XdgFrozenFullscreen { - pub(in crate::backend::wayland) fn request(&mut self, now: Instant) { - self.state = XdgFrozenFullscreenState::PendingConfigure; - self.requested_at = Some(now); - } +mod output_membership; +use output_membership::OutputMembership; - pub(in crate::backend::wayland) fn activate(&mut self) { - self.state = XdgFrozenFullscreenState::Active; - self.requested_at = None; - } - - pub(in crate::backend::wayland) fn finish(&mut self) { - self.state = XdgFrozenFullscreenState::Inactive; - self.requested_at = None; - } - - pub(in crate::backend::wayland) fn timeout(&self, now: Instant) -> Option { - if !self.pending_configure() { - return None; - } - Some( - self.requested_at - .and_then(|requested_at| requested_at.checked_add(XDG_FROZEN_FULLSCREEN_TIMEOUT)) - .map(|deadline| deadline.saturating_duration_since(now)) - .unwrap_or(Duration::ZERO), - ) - } - - pub(in crate::backend::wayland) fn pending_configure(&self) -> bool { - self.state == XdgFrozenFullscreenState::PendingConfigure - } - - pub(in crate::backend::wayland) fn requested(&self) -> bool { - self.state != XdgFrozenFullscreenState::Inactive - } -} - -#[derive(Debug)] -pub(in crate::backend::wayland) struct SurfacePlacement { - preferred_output_identity: Option, - xdg_fullscreen: bool, - main_surface_uses_overlay_layer: bool, - xdg_frozen: XdgFrozenFullscreen, -} - -impl SurfacePlacement { - pub(in crate::backend::wayland) fn new( - preferred_output_identity: Option, - xdg_fullscreen: bool, - main_surface_uses_overlay_layer: bool, - ) -> Self { - Self { - preferred_output_identity, - xdg_fullscreen, - main_surface_uses_overlay_layer, - xdg_frozen: XdgFrozenFullscreen::default(), - } - } - - pub(in crate::backend::wayland) fn preferred_output_identity(&self) -> Option<&str> { - self.preferred_output_identity.as_deref() - } - - pub(in crate::backend::wayland) fn xdg_fullscreen(&self) -> bool { - self.xdg_fullscreen - } - - pub(in crate::backend::wayland) fn layer( - &self, - ) -> smithay_client_toolkit::shell::wlr_layer::Layer { - if self.main_surface_uses_overlay_layer { - smithay_client_toolkit::shell::wlr_layer::Layer::Overlay - } else { - smithay_client_toolkit::shell::wlr_layer::Layer::Top - } - } - - pub(in crate::backend::wayland) fn xdg_frozen(&self) -> &XdgFrozenFullscreen { - &self.xdg_frozen - } - - pub(in crate::backend::wayland) fn xdg_frozen_mut(&mut self) -> &mut XdgFrozenFullscreen { - &mut self.xdg_frozen - } -} +mod placement; +pub(in crate::backend::wayland) use placement::SurfacePlacement; /// A buffer handed out for one frame, plus the pool identity the damage /// tracker needs to tell slot reuse from pool reallocation. @@ -192,7 +94,7 @@ pub struct SurfaceState { pool_generation: u64, /// Last known pool size, used to detect pool growth. pool_size: usize, - current_output: Option, + outputs: OutputMembership, width: u32, height: u32, scale: i32, @@ -211,7 +113,7 @@ impl SurfaceState { slots: Vec::new(), pool_generation: 0, pool_size: 0, - current_output: None, + outputs: OutputMembership::default(), width: 0, height: 0, scale: 1, @@ -229,12 +131,16 @@ impl SurfaceState { } /// Assigns the layer surface produced during startup. - pub fn set_layer_surface(&mut self, surface: LayerSurface) { + pub fn set_layer_surface( + &mut self, + surface: LayerSurface, + output: Option, + ) { self.wl_surface = Some(surface.wl_surface().clone()); self.kind = Some(SurfaceKind::Layer(surface)); // A new shell surface invalidates current buffer resources/state. self.drop_pool(); - self.current_output = None; + self.outputs = OutputMembership::for_layer(output); self.configured = false; self.frame_callbacks.clear(); } @@ -245,7 +151,7 @@ impl SurfaceState { self.kind = Some(SurfaceKind::Xdg { window }); // A new shell surface invalidates current buffer resources/state. self.drop_pool(); - self.current_output = None; + self.outputs = OutputMembership::default(); self.configured = false; self.frame_callbacks.clear(); } @@ -284,21 +190,30 @@ impl SurfaceState { matches!(self.kind, Some(SurfaceKind::Xdg { .. })) } - /// Records the most recent output the surface entered. - pub fn set_current_output(&mut self, output: wl_output::WlOutput) { - self.current_output = Some(output); + /// Keeps an explicitly requested output until the compositor enters or removes it. + pub fn request_output(&mut self, output: wl_output::WlOutput) { + self.outputs.request_output(output); + } + + /// Fills an empty selection with a guess that the first compositor enter replaces. + pub fn assume_output(&mut self, output: wl_output::WlOutput) { + self.outputs.assume(output); + } + + /// Retain the current entered output when the surface overlaps a neighbour. + /// Explicit layer placement takes precedence until the output is left. + pub fn enter_output(&mut self, output: wl_output::WlOutput) { + self.outputs.enter(output); } - /// Clears the current output if it matches the provided handle. + /// Remove only this output, preserving any other entered outputs. pub fn clear_output(&mut self, output: &wl_output::WlOutput) { - if self.current_output.as_ref() == Some(output) { - self.current_output = None; - } + self.outputs.remove(output); } - /// Returns the last known output for this surface, if any. + /// Returns the selected output for this surface, if any. pub fn current_output(&self) -> Option { - self.current_output.clone() + self.outputs.current() } /// Updates the surface dimensions, returning `true` if the size changed. @@ -569,49 +484,6 @@ mod tests { assert!(surface.has_available_buffer(0)); } - #[test] - fn frozen_fullscreen_deadline_uses_injected_time() { - let start = Instant::now(); - let mut state = XdgFrozenFullscreen::default(); - state.request(start); - - assert_eq!(state.timeout(start), Some(XDG_FROZEN_FULLSCREEN_TIMEOUT)); - assert_eq!( - state.timeout(start + XDG_FROZEN_FULLSCREEN_TIMEOUT), - Some(Duration::ZERO) - ); - assert!(state.pending_configure()); - assert!(state.requested()); - } - - #[test] - fn frozen_fullscreen_activate_and_finish_clear_pending_timeout() { - let start = Instant::now(); - let mut state = XdgFrozenFullscreen::default(); - state.request(start); - state.activate(); - - assert!(state.requested()); - assert!(!state.pending_configure()); - assert_eq!(state.timeout(start + XDG_FROZEN_FULLSCREEN_TIMEOUT), None); - - state.finish(); - assert!(!state.requested()); - assert_eq!(state.timeout(start), None); - } - - #[test] - fn placement_keeps_output_fullscreen_and_layer_policy_together() { - let placement = SurfacePlacement::new(Some("DP-1".to_owned()), true, true); - - assert_eq!(placement.preferred_output_identity(), Some("DP-1")); - assert!(placement.xdg_fullscreen()); - assert_eq!( - placement.layer(), - smithay_client_toolkit::shell::wlr_layer::Layer::Overlay - ); - } - #[test] fn retired_callback_cannot_clear_a_newer_render_throttle() { let mut callbacks = FrameCallbackTracker::default(); diff --git a/src/backend/wayland/surface/output_membership.rs b/src/backend/wayland/surface/output_membership.rs new file mode 100644 index 00000000..1894e2d7 --- /dev/null +++ b/src/backend/wayland/surface/output_membership.rs @@ -0,0 +1,139 @@ +//! Compositor membership and explicit placement of one overlay surface. +use wayland_client::protocol::wl_output::WlOutput; + +#[derive(Default)] +pub(super) struct OutputMembership { + current: Option, + entered: Vec, + layer_output: Option, + awaiting_enter: bool, +} + +impl OutputMembership { + pub(super) fn for_layer(output: Option) -> Self { + Self { + current: output.clone(), + awaiting_enter: output.is_some(), + layer_output: output, + entered: Vec::new(), + } + } + + pub(super) fn current(&self) -> Option { + self.current.clone() + } + + /// Keep an explicit XDG fullscreen target until it enters or is removed. + pub(super) fn request_output(&mut self, output: WlOutput) { + self.current = Some(output); + self.awaiting_enter = true; + } + + /// Use a guess only while nothing is selected. Unlike an explicit request, + /// it never outlives the compositor's first enter for another output. + pub(super) fn assume(&mut self, output: WlOutput) { + if self.current.is_none() { + self.current = Some(output); + } + } + + pub(super) fn enter(&mut self, output: WlOutput) { + if self.current.as_ref() == Some(&output) { + self.awaiting_enter = false; + } + if !self.entered.contains(&output) { + self.entered.push(output); + } + + self.select_output(); + } + + pub(super) fn remove(&mut self, output: &WlOutput) { + self.entered.retain(|entered| entered != output); + if self.current.as_ref() == Some(output) { + self.current = None; + self.awaiting_enter = false; + } + + self.select_output(); + } + + fn select_output(&mut self) { + self.current = self + .layer_output + .as_ref() + .filter(|output| { + self.entered.contains(output) + || (self.awaiting_enter && self.current.as_ref() == Some(output)) + }) + .or_else(|| { + self.current.as_ref().filter(|output| { + self.awaiting_enter || self.entered.contains(output) || self.entered.is_empty() + }) + }) + .or_else(|| self.entered.first()) + .cloned(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::handlers::test_support::HandlerFixture; + + #[test] + fn layer_placement_takes_precedence_and_falls_back_to_remaining_output() { + let fixture = HandlerFixture::with_outputs(crate::config::Config::default(), 2); + let outputs: Vec<_> = fixture.state.protocol.output().outputs().collect(); + let mut membership = OutputMembership::for_layer(Some(outputs[1].clone())); + + membership.enter(outputs[0].clone()); + assert_eq!(membership.current(), Some(outputs[1].clone())); + + membership.enter(outputs[1].clone()); + membership.enter(outputs[0].clone()); + assert_eq!(membership.current(), Some(outputs[1].clone())); + + membership.remove(&outputs[1]); + assert_eq!(membership.current(), Some(outputs[0].clone())); + + membership.enter(outputs[1].clone()); + assert_eq!(membership.current(), Some(outputs[1].clone())); + + membership.remove(&outputs[1]); + membership.remove(&outputs[0]); + assert!(membership.current().is_none()); + } + + #[test] + fn assumed_output_survives_unrelated_removal_until_the_first_enter() { + let fixture = HandlerFixture::with_outputs(crate::config::Config::default(), 3); + let outputs: Vec<_> = fixture.state.protocol.output().outputs().collect(); + let mut membership = OutputMembership::default(); + membership.assume(outputs[0].clone()); + + membership.remove(&outputs[2]); + assert_eq!(membership.current(), Some(outputs[0].clone())); + + membership.enter(outputs[1].clone()); + assert_eq!(membership.current(), Some(outputs[1].clone())); + + membership.assume(outputs[0].clone()); + assert_eq!(membership.current(), Some(outputs[1].clone())); + } + + #[test] + fn unrelated_removal_preserves_pre_enter_placement_but_target_loss_releases_it() { + let fixture = HandlerFixture::with_outputs(crate::config::Config::default(), 2); + let outputs: Vec<_> = fixture.state.protocol.output().outputs().collect(); + let mut membership = OutputMembership::default(); + membership.request_output(outputs[0].clone()); + + membership.remove(&outputs[1]); + assert_eq!(membership.current(), Some(outputs[0].clone())); + + membership.enter(outputs[1].clone()); + membership.remove(&outputs[0]); + assert_eq!(membership.current(), Some(outputs[1].clone())); + } +} diff --git a/src/backend/wayland/surface/placement.rs b/src/backend/wayland/surface/placement.rs new file mode 100644 index 00000000..3b0be899 --- /dev/null +++ b/src/backend/wayland/surface/placement.rs @@ -0,0 +1,152 @@ +//! Layer policy and bounded XDG fullscreen placement for frozen mode. +use std::time::{Duration, Instant}; + +const XDG_FROZEN_FULLSCREEN_TIMEOUT: Duration = Duration::from_millis(1500); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum XdgFrozenFullscreenState { + #[default] + Inactive, + PendingConfigure, + Active, +} + +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct XdgFrozenFullscreen { + state: XdgFrozenFullscreenState, + requested_at: Option, +} + +impl XdgFrozenFullscreen { + pub(in crate::backend::wayland) fn request(&mut self, now: Instant) { + self.state = XdgFrozenFullscreenState::PendingConfigure; + self.requested_at = Some(now); + } + + pub(in crate::backend::wayland) fn activate(&mut self) { + self.state = XdgFrozenFullscreenState::Active; + self.requested_at = None; + } + + pub(in crate::backend::wayland) fn finish(&mut self) { + self.state = XdgFrozenFullscreenState::Inactive; + self.requested_at = None; + } + + pub(in crate::backend::wayland) fn timeout(&self, now: Instant) -> Option { + if !self.pending_configure() { + return None; + } + Some( + self.requested_at + .and_then(|requested_at| requested_at.checked_add(XDG_FROZEN_FULLSCREEN_TIMEOUT)) + .map(|deadline| deadline.saturating_duration_since(now)) + .unwrap_or(Duration::ZERO), + ) + } + + pub(in crate::backend::wayland) fn pending_configure(&self) -> bool { + self.state == XdgFrozenFullscreenState::PendingConfigure + } + + pub(in crate::backend::wayland) fn requested(&self) -> bool { + self.state != XdgFrozenFullscreenState::Inactive + } +} + +#[derive(Debug)] +pub(in crate::backend::wayland) struct SurfacePlacement { + preferred_output_identity: Option, + xdg_fullscreen: bool, + main_surface_uses_overlay_layer: bool, + xdg_frozen: XdgFrozenFullscreen, +} + +impl SurfacePlacement { + pub(in crate::backend::wayland) fn new( + preferred_output_identity: Option, + xdg_fullscreen: bool, + main_surface_uses_overlay_layer: bool, + ) -> Self { + Self { + preferred_output_identity, + xdg_fullscreen, + main_surface_uses_overlay_layer, + xdg_frozen: XdgFrozenFullscreen::default(), + } + } + + pub(in crate::backend::wayland) fn preferred_output_identity(&self) -> Option<&str> { + self.preferred_output_identity.as_deref() + } + + pub(in crate::backend::wayland) fn xdg_fullscreen(&self) -> bool { + self.xdg_fullscreen + } + + pub(in crate::backend::wayland) fn layer( + &self, + ) -> smithay_client_toolkit::shell::wlr_layer::Layer { + if self.main_surface_uses_overlay_layer { + smithay_client_toolkit::shell::wlr_layer::Layer::Overlay + } else { + smithay_client_toolkit::shell::wlr_layer::Layer::Top + } + } + + pub(in crate::backend::wayland) fn xdg_frozen(&self) -> &XdgFrozenFullscreen { + &self.xdg_frozen + } + + pub(in crate::backend::wayland) fn xdg_frozen_mut(&mut self) -> &mut XdgFrozenFullscreen { + &mut self.xdg_frozen + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frozen_fullscreen_deadline_uses_injected_time() { + let start = Instant::now(); + let mut state = XdgFrozenFullscreen::default(); + state.request(start); + + assert_eq!(state.timeout(start), Some(XDG_FROZEN_FULLSCREEN_TIMEOUT)); + assert_eq!( + state.timeout(start + XDG_FROZEN_FULLSCREEN_TIMEOUT), + Some(Duration::ZERO) + ); + assert!(state.pending_configure()); + assert!(state.requested()); + } + + #[test] + fn frozen_fullscreen_activate_and_finish_clear_pending_timeout() { + let start = Instant::now(); + let mut state = XdgFrozenFullscreen::default(); + state.request(start); + state.activate(); + + assert!(state.requested()); + assert!(!state.pending_configure()); + assert_eq!(state.timeout(start + XDG_FROZEN_FULLSCREEN_TIMEOUT), None); + + state.finish(); + assert!(!state.requested()); + assert_eq!(state.timeout(start), None); + } + + #[test] + fn placement_keeps_output_fullscreen_and_layer_policy_together() { + let placement = SurfacePlacement::new(Some("DP-1".to_owned()), true, true); + + assert_eq!(placement.preferred_output_identity(), Some("DP-1")); + assert!(placement.xdg_fullscreen()); + assert_eq!( + placement.layer(), + smithay_client_toolkit::shell::wlr_layer::Layer::Overlay + ); + } +} diff --git a/src/backend/wayland/zoom/AGENTS.md b/src/backend/wayland/zoom/AGENTS.md index abab5ac2..9ec14fdb 100644 --- a/src/backend/wayland/zoom/AGENTS.md +++ b/src/backend/wayland/zoom/AGENTS.md @@ -7,7 +7,8 @@ - `capture.rs` coordinates zoom capture. - `portal.rs` provides portal fallback behavior. - `state.rs` owns zoom image/view state and distinguishes direct and portal capture backends. `state/source.rs` owns identified request IDs, waiters, terminal reports, and the shared terminal cleanup that the lifecycle entry points in `state.rs` (deactivate, cancel, fail, stale direct capture) delegate to; `abort_capture` stays in `state.rs` because it ends a request without touching view or input state. -- `retry.rs` retains capture ID, waiter, and requested activation through the shared portal settling policy and fresh preflight. +- `retry.rs` retains capture ID, waiter, requested activation, and selected backend through the shared layout settling policy and fresh preflight. +- `state.rs::handle_output_change` retains unbound capture requests during output discovery and the retry wait; bound requests and installed sources deactivate on an output change. `../state/core/output.rs` publishes output changes for protocol handlers and explicit switches; repeated enters preserve the current source. - `view.rs` owns zoom view transforms. ## Invariants diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index e1e78ca4..f9e09ecd 100644 --- a/src/backend/wayland/zoom/capture.rs +++ b/src/backend/wayland/zoom/capture.rs @@ -157,8 +157,9 @@ impl ZoomState { State: Dispatch + Dispatch + 'static, { - self.ensure_preflight_layout_current() + self.ensure_preflight_admission() .map_err(anyhow::Error::msg)?; + if backend == ZoomCaptureBackend::Portal { info!("capture.preflight component=zoom phase=portal-start suppression_ready=true"); self.capture_via_portal(tokio_handle) diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index ec5cb3ba..45e93a48 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -600,7 +600,7 @@ mod tests { let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); - zoom.layout_retry = crate::backend::wayland::capture_preflight::PortalLayoutRetry::Spent; + zoom.layout_retry = crate::backend::wayland::capture_preflight::CaptureLayoutRetry::Spent; zoom.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_geometry(Some(crop_geometry((0, 0)))); @@ -688,7 +688,7 @@ mod tests { let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); - zoom.layout_retry = crate::backend::wayland::capture_preflight::PortalLayoutRetry::Spent; + zoom.layout_retry = crate::backend::wayland::capture_preflight::CaptureLayoutRetry::Spent; zoom.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_geometry(Some(crop_geometry((0, 0)).with_known_output_count(Some(1)))); diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs index 0e36e7ca..c5d3be2e 100644 --- a/src/backend/wayland/zoom/retry.rs +++ b/src/backend/wayland/zoom/retry.rs @@ -13,6 +13,7 @@ impl ZoomState { } if !self.layout_retry.schedule( + ZoomCaptureBackend::Portal, captured, self.active_output_id, layout_changed, @@ -22,8 +23,7 @@ impl ZoomState { return false; } - self.portal.finish(); - self.capture_done = false; + self.reset_for_retry(); log::info!( "portal.zoom phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", self.layout_generations.desktop @@ -32,22 +32,35 @@ impl ZoomState { true } - pub(in crate::backend::wayland) fn retry_stale_portal_preflight( - &mut self, - backend: ZoomCaptureBackend, - ) -> bool { - let portal_layout_changed = (backend == ZoomCaptureBackend::Portal) - && self - .preflight - .changed_on_output(self.active_output_id, self.layout_generations.desktop); - self.queue_portal_layout_retry(self.active_output_id, portal_layout_changed) + pub(in crate::backend::wayland) fn retry_stale_preflight(&mut self) -> bool { + if self.current_capture_id().is_none() { + return false; + } + + let queued = self.layout_retry.queue_preflight( + &self.preflight, + self.active_output_id, + self.layout_generations, + Instant::now(), + ); + if queued { + self.reset_for_retry(); + } + + queued } - pub(in crate::backend::wayland) fn has_portal_layout_retry(&self) -> bool { + /// Drops the attempt the retry replaces; the capture ID and waiter stay. + fn reset_for_retry(&mut self) { + self.portal.finish(); + self.capture_done = false; + } + + pub(in crate::backend::wayland) fn has_layout_retry(&self) -> bool { self.layout_retry.is_pending() } - pub(in crate::backend::wayland) fn portal_layout_retry_timeout( + pub(in crate::backend::wayland) fn layout_retry_timeout( &self, now: Instant, ) -> Option { @@ -55,34 +68,17 @@ impl ZoomState { } /// Retains the original capture ID, waiter, and requested Zoom activation. - pub(in crate::backend::wayland) fn restart_portal_preflight( + pub(in crate::backend::wayland) fn restart_preflight( &mut self, now: Instant, ) -> Result { - let Some(output_id) = self - .layout_retry - .take_ready( - self.active_output_id, - self.layout_generations.desktop, - self.active_geometry.as_ref(), - now, - ) - .map_err(CapturePreflightError::from)? - else { - return Ok(false); - }; - - self.preflight.begin( - ZoomCaptureBackend::Portal, - Some(output_id), - self.layout_generations.desktop, - ); - log::info!( - "portal.zoom phase=retry-preflight output={output_id} layout={}", - self.layout_generations.desktop - ); - - Ok(true) + self.layout_retry.restart_preflight( + &mut self.preflight, + self.active_output_id, + self.layout_generations, + self.active_geometry.as_ref(), + now, + ) } } @@ -180,13 +176,13 @@ mod tests { drain(&mut zoom, &mut input).await; assert!(zoom.is_in_progress()); - assert!(zoom.has_portal_layout_retry()); + assert!(zoom.has_layout_retry()); assert!(zoom.pending_activation); assert!(!zoom.take_capture_done()); assert!(zoom.take_source_terminal().is_none()); assert_eq!(zoom.current_capture_id(), Some(id)); assert!( - zoom.restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + zoom.restart_preflight(Instant::now() + Duration::from_millis(150)) .unwrap() ); assert_eq!( @@ -224,7 +220,7 @@ mod tests { let terminal = zoom.take_source_terminal().unwrap(); assert_eq!(terminal.id, id); assert!(waiters.take_for_terminal(&terminal).unwrap().1); - assert!(!zoom.has_portal_layout_retry()); + assert!(!zoom.has_layout_retry()); assert!(zoom.take_capture_done()); assert!(zoom.take_source_terminal().is_none()); @@ -282,7 +278,7 @@ mod tests { drain(&mut zoom, &mut input).await; - assert!(!zoom.has_portal_layout_retry()); + assert!(!zoom.has_layout_retry()); assert!(!zoom.is_in_progress()); let terminal = zoom.take_source_terminal().unwrap(); @@ -309,16 +305,16 @@ mod tests { zoom.set_active_output(None, Some(2)); assert!( - zoom.restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + zoom.restart_preflight(Instant::now() + Duration::from_millis(150)) .is_err() ); } assert!(zoom.abort_capture()); - assert!(!zoom.has_portal_layout_retry()); + assert!(!zoom.has_layout_retry()); assert!( !zoom - .restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .restart_preflight(Instant::now() + Duration::from_millis(150)) .unwrap() ); assert_eq!(zoom.take_source_terminal().unwrap().id, id); diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index d3428049..0ebb2191 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -1,6 +1,8 @@ use crate::backend::wayland::capture_preflight::{ - CaptureLayoutGenerations, CapturePreflight, CapturePreflightError, PortalLayoutRetry, + CaptureBackend, CaptureLayoutGenerations, CaptureLayoutRetry, CaptureLayoutScope, + CapturePreflight, CapturePreflightError, }; +use crate::backend::wayland::state::OverlaySuppression; use std::sync::Arc; use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; @@ -27,6 +29,19 @@ pub(in crate::backend::wayland) enum ZoomCaptureBackend { Portal, } +impl CaptureBackend for ZoomCaptureBackend { + fn layout_scope(self) -> CaptureLayoutScope { + match self { + Self::Portal => CaptureLayoutScope::Desktop, + Self::WlrScreencopy => CaptureLayoutScope::ActiveOutput, + } + } + + fn suppression_reason(self) -> OverlaySuppression { + OverlaySuppression::Zoom + } +} + /// Zoom state, capture logic, and pan/lock bookkeeping. pub struct ZoomState { pub(super) manager: Option, @@ -44,7 +59,7 @@ pub struct ZoomState { pub(super) runtime_wake: Option, pub(super) preflight: CapturePreflight, pub(super) capture_done: bool, - pub(super) layout_retry: PortalLayoutRetry, + pub(super) layout_retry: CaptureLayoutRetry, next_capture_id: u64, current_capture_id: Option, pub(super) source_terminal: Option, @@ -89,7 +104,7 @@ impl ZoomState { runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, - layout_retry: PortalLayoutRetry::default(), + layout_retry: CaptureLayoutRetry::default(), next_capture_id: 1, current_capture_id: None, source_terminal: None, @@ -224,22 +239,35 @@ impl ZoomState { } pub(super) fn capture_layout_generation(&self, backend: ZoomCaptureBackend) -> u64 { - match backend { - ZoomCaptureBackend::Portal => self.layout_generations.desktop, - ZoomCaptureBackend::WlrScreencopy => self.layout_generations.active_output, - } + backend.layout_generation(self.layout_generations) } + #[cfg(test)] pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), CapturePreflightError> { self.preflight.ensure_layout_current( self.active_output_id, - self.preflight - .backend() - .map(|backend| self.capture_layout_generation(backend)) - .unwrap_or(self.layout_generations.active_output), + self.preflight.generation(self.layout_generations), + ) + } + + /// Admission needs a known active output and the layout saved when the + /// request began; an unknown output is retried, never captured. + pub(super) fn ensure_preflight_admission(&self) -> Result<(), CapturePreflightError> { + self.preflight.ensure_admission_current( + self.active_output_id, + self.preflight.generation(self.layout_generations), ) } + pub(in crate::backend::wayland) fn log_preflight_layout(&self, barrier_id: Option) { + self.preflight.log_layout( + "snapshot", + barrier_id, + self.active_output_id, + self.layout_generations, + ); + } + #[cfg(test)] pub fn preflight_layout_is_current(&self) -> bool { self.ensure_preflight_layout_current().is_ok() @@ -280,7 +308,7 @@ impl ZoomState { changed = true; } self.preflight = CapturePreflight::Idle; - self.layout_retry = PortalLayoutRetry::default(); + self.layout_retry = CaptureLayoutRetry::default(); self.portal.finish(); self.pending_activation = false; if changed { @@ -294,6 +322,25 @@ impl ZoomState { self.cancel_with_outcome(input_state, true, ZoomSourceOutcome::Deactivated); } + /// Keep an unbound request alive through admission and the settling wait. + /// Bound requests and installed images are invalidated by an output change. + pub(in crate::backend::wayland) fn handle_output_change( + &mut self, + input_state: &mut InputState, + ) { + if self.preflight.awaiting_output() { + self.preflight.log_layout( + "output-change-retained", + None, + self.active_output_id, + self.layout_generations, + ); + return; + } + + self.deactivate(input_state); + } + pub fn reset_view(&mut self) { self.scale = MIN_ZOOM_SCALE; self.view_offset = (0.0, 0.0); diff --git a/src/backend/wayland/zoom/state/source.rs b/src/backend/wayland/zoom/state/source.rs index 8bb280b1..f368cb1e 100644 --- a/src/backend/wayland/zoom/state/source.rs +++ b/src/backend/wayland/zoom/state/source.rs @@ -110,7 +110,7 @@ impl ZoomState { .checked_add(1) .expect("zoom capture id space exhausted"); self.current_capture_id = Some(id); - self.layout_retry = PortalLayoutRetry::default(); + self.layout_retry = CaptureLayoutRetry::default(); id } @@ -127,7 +127,7 @@ impl ZoomState { outcome: ZoomSourceOutcome, report: Option, ) { - self.layout_retry = PortalLayoutRetry::default(); + self.layout_retry = CaptureLayoutRetry::default(); let Some(id) = self.current_capture_id.take() else { return; }; @@ -187,7 +187,7 @@ impl ZoomState { capture.frame.destroy(); } self.preflight = CapturePreflight::Idle; - self.layout_retry = PortalLayoutRetry::default(); + self.layout_retry = CaptureLayoutRetry::default(); self.capture_done = true; self.portal.finish(); self.pending_activation = false;