From 0d6c4bd8d2d104d7d2e0ebc964a620ab7c7dfd41 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:23:54 +0200 Subject: [PATCH 1/3] feat(capture): establish shared screen-region acquisition lifecycle - correlate frozen and zoom completions with typed owners and IDs - preserve OCR and eyedropper capture ownership across modal transitions - fail closed on stale or mismatched capture terminals - add native-pixel geometry and PNG primitives for region capture --- src/backend/wayland/acquisition.rs | 260 ++++ .../wayland/backend/event_loop/capture.rs | 234 ++- src/backend/wayland/capture.rs | 75 +- src/backend/wayland/clipboard/file_list.rs | 17 +- src/backend/wayland/clipboard/image.rs | 15 +- src/backend/wayland/clipboard/mod.rs | 2 - src/backend/wayland/clipboard/transfer.rs | 7 +- src/backend/wayland/frozen/capture.rs | 5 + src/backend/wayland/frozen/mod.rs | 31 + src/backend/wayland/frozen/portal.rs | 156 +- src/backend/wayland/frozen/state.rs | 624 +++++++- src/backend/wayland/handlers/keyboard/mod.rs | 43 +- src/backend/wayland/handlers/pointer/mod.rs | 2 +- .../wayland/handlers/pointer/motion.rs | 4 +- src/backend/wayland/handlers/pointer/press.rs | 6 +- .../wayland/handlers/pointer/release.rs | 4 +- src/backend/wayland/handlers/tablet/frame.rs | 14 +- src/backend/wayland/handlers/tablet/tool.rs | 36 +- src/backend/wayland/handlers/touch.rs | 22 +- src/backend/wayland/mod.rs | 1 + src/backend/wayland/state.rs | 2 + src/backend/wayland/state/acquisition.rs | 1300 +++++++++++++++++ src/backend/wayland/state/capture.rs | 21 +- src/backend/wayland/state/capture/barrier.rs | 167 ++- src/backend/wayland/state/core/accessors.rs | 38 +- src/backend/wayland/state/data.rs | 12 + src/backend/wayland/state/eyedropper.rs | 181 +-- src/backend/wayland/state/ocr.rs | 253 ++-- src/backend/wayland/state/region_capture.rs | 687 +++++++++ src/backend/wayland/state/screen_image.rs | 613 ++++++-- src/backend/wayland/zoom/capture.rs | 54 +- src/backend/wayland/zoom/mod.rs | 6 + src/backend/wayland/zoom/portal.rs | 222 ++- src/backend/wayland/zoom/state.rs | 435 +++++- src/capture/mod.rs | 1 + src/capture/png.rs | 83 ++ src/input/mod.rs | 5 +- src/input/state/core/base/state/init.rs | 2 +- src/input/state/core/base/state/structs.rs | 5 +- src/input/state/core/eyedropper.rs | 83 +- src/input/state/core/mod.rs | 7 +- src/input/state/core/modal.rs | 4 +- src/input/state/core/ocr.rs | 531 ------- src/input/state/core/region_select.rs | 620 ++++++++ src/input/state/core/utility/toasts.rs | 15 + src/input/state/mod.rs | 18 +- src/lib.rs | 1 + src/ocr/controller.rs | 12 +- src/ocr/mod.rs | 108 +- src/screen_pixels.rs | 642 ++++++++ src/ui.rs | 2 + src/ui/eyedropper_loupe.rs | 165 +++ 52 files changed, 6486 insertions(+), 1367 deletions(-) create mode 100644 src/backend/wayland/acquisition.rs create mode 100644 src/backend/wayland/state/acquisition.rs create mode 100644 src/backend/wayland/state/region_capture.rs create mode 100644 src/capture/png.rs delete mode 100644 src/input/state/core/ocr.rs create mode 100644 src/input/state/core/region_select.rs create mode 100644 src/screen_pixels.rs create mode 100644 src/ui/eyedropper_loupe.rs diff --git a/src/backend/wayland/acquisition.rs b/src/backend/wayland/acquisition.rs new file mode 100644 index 000000000..211df1f8e --- /dev/null +++ b/src/backend/wayland/acquisition.rs @@ -0,0 +1,260 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(in crate::backend::wayland) struct ScreenAcquisitionId(u64); + +impl ScreenAcquisitionId { + #[cfg(test)] + fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[allow(dead_code)] // Modal owners are connected by the Phase-0a owner adapters. +pub(in crate::backend::wayland) enum ScreenAcquisitionOwner { + UserFreeze, + Eyedropper, + Ocr, + RegionCapture, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum ScreenAcquisitionOutcome { + Ready { installed_generation: u64 }, + Cancelled, + Unavailable, + StaleLayout, + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ScreenAcquisitionCompletion { + pub id: ScreenAcquisitionId, + pub owner: ScreenAcquisitionOwner, + pub outcome: ScreenAcquisitionOutcome, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum AcquisitionStage { + Queued, + Started, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct AcquisitionRecord { + pub id: ScreenAcquisitionId, + pub owner: ScreenAcquisitionOwner, + pub stage: AcquisitionStage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ScreenAcquisitionBusy; + +#[derive(Debug)] +pub(in crate::backend::wayland) struct ScreenAcquisitionRegistry { + next_id: u64, + slot: Option, +} + +impl Default for ScreenAcquisitionRegistry { + fn default() -> Self { + Self { + next_id: 1, + slot: None, + } + } +} + +impl ScreenAcquisitionRegistry { + pub fn request( + &mut self, + owner: ScreenAcquisitionOwner, + ) -> Result { + if self.slot.is_some() { + return Err(ScreenAcquisitionBusy); + } + let id = ScreenAcquisitionId(self.next_id); + self.next_id = self + .next_id + .checked_add(1) + .expect("screen acquisition id space exhausted"); + self.slot = Some(AcquisitionRecord { + id, + owner, + stage: AcquisitionStage::Queued, + }); + Ok(id) + } + + pub fn slot(&self) -> Option<&AcquisitionRecord> { + self.slot.as_ref() + } + + pub fn mark_started(&mut self, id: ScreenAcquisitionId, owner: ScreenAcquisitionOwner) -> bool { + let Some(record) = self.slot.as_mut() else { + return false; + }; + if record.id != id || record.owner != owner || record.stage != AcquisitionStage::Queued { + return false; + } + record.stage = AcquisitionStage::Started; + true + } + + pub fn take(&mut self) -> Option { + self.slot.take() + } + + #[allow(dead_code)] // Used by public modal-owner cancellation adapters. + pub fn take_matching( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + if !self + .slot + .as_ref() + .is_some_and(|record| record.id == id && record.owner == owner) + { + return None; + } + self.slot.take() + } +} + +pub(in crate::backend::wayland) fn rejected_ready_generation( + owner: ScreenAcquisitionOwner, + outcome: &ScreenAcquisitionOutcome, + current_generation: u64, + frozen_active: bool, +) -> Option { + if owner == ScreenAcquisitionOwner::UserFreeze || !frozen_active { + return None; + } + let ScreenAcquisitionOutcome::Ready { + installed_generation, + } = outcome + else { + return None; + }; + (*installed_generation == current_generation).then_some(*installed_generation) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_allocates_monotonic_ids_and_takes_only_matching_records() { + let mut registry = ScreenAcquisitionRegistry::default(); + + let first = registry + .request(ScreenAcquisitionOwner::Ocr) + .expect("empty registry accepts a request"); + assert_eq!(first.get(), 1); + assert_eq!( + registry.request(ScreenAcquisitionOwner::Eyedropper), + Err(ScreenAcquisitionBusy) + ); + assert_eq!( + registry.take_matching(first, ScreenAcquisitionOwner::Eyedropper), + None + ); + assert_eq!( + registry.take_matching(first, ScreenAcquisitionOwner::Ocr), + Some(AcquisitionRecord { + id: first, + owner: ScreenAcquisitionOwner::Ocr, + stage: AcquisitionStage::Queued, + }) + ); + + let second = registry + .request(ScreenAcquisitionOwner::UserFreeze) + .expect("released registry accepts another request"); + assert_eq!(second.get(), 2); + assert_eq!( + registry.take_matching(first, ScreenAcquisitionOwner::UserFreeze), + None, + "a stale id must not consume the replacement acquisition" + ); + assert_eq!( + registry.slot(), + Some(&AcquisitionRecord { + id: second, + owner: ScreenAcquisitionOwner::UserFreeze, + stage: AcquisitionStage::Queued, + }) + ); + } + + #[test] + fn rejected_ready_release_is_modal_generation_checked() { + let ready = ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }; + + assert_eq!( + rejected_ready_generation(ScreenAcquisitionOwner::Ocr, &ready, 7, true,), + Some(7) + ); + assert_eq!( + rejected_ready_generation(ScreenAcquisitionOwner::UserFreeze, &ready, 7, true,), + None + ); + assert_eq!( + rejected_ready_generation(ScreenAcquisitionOwner::Eyedropper, &ready, 8, true,), + None + ); + assert_eq!( + rejected_ready_generation( + ScreenAcquisitionOwner::RegionCapture, + &ScreenAcquisitionOutcome::StaleLayout, + 7, + true, + ), + None + ); + assert_eq!( + rejected_ready_generation(ScreenAcquisitionOwner::Ocr, &ready, 7, false,), + None + ); + } + + #[test] + fn started_owner_cancellation_takes_the_registry_record_once_and_preserves_replacement() { + let mut registry = ScreenAcquisitionRegistry::default(); + let first = registry + .request(ScreenAcquisitionOwner::Ocr) + .expect("first request"); + assert!(registry.mark_started(first, ScreenAcquisitionOwner::Ocr)); + + assert_eq!( + registry.take_matching(first, ScreenAcquisitionOwner::Ocr), + Some(AcquisitionRecord { + id: first, + owner: ScreenAcquisitionOwner::Ocr, + stage: AcquisitionStage::Started, + }) + ); + assert_eq!( + registry.take_matching(first, ScreenAcquisitionOwner::Ocr), + None + ); + + let replacement = registry + .request(ScreenAcquisitionOwner::Eyedropper) + .expect("replacement request"); + assert_eq!( + registry.take_matching(first, ScreenAcquisitionOwner::Ocr), + None + ); + assert_eq!( + registry.slot(), + Some(&AcquisitionRecord { + id: replacement, + owner: ScreenAcquisitionOwner::Eyedropper, + stage: AcquisitionStage::Queued, + }) + ); + } +} diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 4ea35ba70..bcef94cd7 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -1,3 +1,6 @@ +use crate::backend::wayland::acquisition::{ + AcquisitionRecord, AcquisitionStage, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, +}; use crate::input::state::{Toast, ToastPriority}; use log::{info, warn}; use std::time::{Duration, Instant}; @@ -69,13 +72,22 @@ fn handle_pending_frozen_image(state: &mut WaylandState, now: Instant) { if state.xdg_frozen_fullscreen_pending_configure() { if state.xdg_frozen_fullscreen_timed_out(now) { warn!("Frozen xdg fullscreen configure timed out; cancelling freeze"); - state.input_state.push_toast( - ToastPriority::Critical, - "capture", - Toast::error("Freeze failed because fullscreen was not confirmed"), - ); state.restore_xdg_after_frozen(); - state.frozen.cancel(&mut state.input_state); + if state.frozen.has_acquisition_attempt() { + state.frozen.finish_acquisition( + ScreenAcquisitionOutcome::Failed( + "Freeze failed because fullscreen was not confirmed".to_string(), + ), + &mut state.input_state, + ); + } else { + state.input_state.push_toast( + ToastPriority::Critical, + "capture", + Toast::error("Freeze failed because fullscreen was not confirmed"), + ); + state.frozen.cancel(&mut state.input_state); + } } return; } @@ -152,44 +164,125 @@ pub(super) fn handle_pending_actions( state.handle_zoom_action(action); } state.sync_zoom_board_mode(); + state.resolve_pending_zoom_terminal(); state.sync_input_monitor_if_changed(); handle_capture_results(state); } -fn handle_frozen_toggle(state: &mut WaylandState) { - if !state.input_state.take_pending_frozen_toggle() { - return; - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrozenUserToggleAction { + None, + AbsorbQueuedModal, + IgnoreInProgress, + Unfreeze, + ReportUnavailable, + RequestUserFreeze, +} - if !state.frozen_enabled() { - warn!( - "Frozen mode unavailable: no direct capture backend and no screenshot portal backend; ignoring toggle" - ); - state.input_state.push_toast( - ToastPriority::Info, - "capture", - Toast::warning("Freeze is unavailable because screen capture is not available."), - ); - } else if state.frozen.is_in_progress() { - warn!("Frozen capture already in progress; ignoring toggle"); - } else if state.input_state.frozen_active() { - state.restore_xdg_after_frozen(); - state.frozen.unfreeze(&mut state.input_state); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FrozenTogglePassDecision { + user_action: FrozenUserToggleAction, + queued_to_start: Option, +} + +fn frozen_toggle_pass_decision( + user_toggle: bool, + slot: Option, + frozen_active: bool, + frozen_enabled: bool, +) -> FrozenTogglePassDecision { + let user_action = if !user_toggle { + FrozenUserToggleAction::None } else { - if !state.enter_overlay_suppression(OverlaySuppression::Frozen) { - warn!("Frozen mode requested while overlay is suppressed; ignoring toggle"); + match slot { + Some(record) + if record.stage == AcquisitionStage::Queued + && record.owner != ScreenAcquisitionOwner::UserFreeze => + { + FrozenUserToggleAction::AbsorbQueuedModal + } + Some(_) => FrozenUserToggleAction::IgnoreInProgress, + None if frozen_active => FrozenUserToggleAction::Unfreeze, + None if !frozen_enabled => FrozenUserToggleAction::ReportUnavailable, + None => FrozenUserToggleAction::RequestUserFreeze, + } + }; + FrozenTogglePassDecision { + user_action, + queued_to_start: slot.filter(|record| record.stage == AcquisitionStage::Queued), + } +} + +fn handle_frozen_toggle(state: &mut WaylandState) { + let decision = frozen_toggle_pass_decision( + state.input_state.take_pending_frozen_toggle(), + state.screen_acquisition_slot(), + state.input_state.frozen_active(), + state.frozen_enabled(), + ); + match decision.user_action { + FrozenUserToggleAction::None => {} + FrozenUserToggleAction::AbsorbQueuedModal => { + let record = decision + .queued_to_start + .expect("absorbed modal acquisition remains queued"); + log::debug!( + "User freeze toggle absorbed by queued {:?} acquisition", + record.owner + ); + } + FrozenUserToggleAction::IgnoreInProgress => { + warn!("Frozen capture already in progress; ignoring toggle"); + } + FrozenUserToggleAction::Unfreeze => { + state.restore_xdg_after_frozen(); + state.frozen.unfreeze(&mut state.input_state); + } + FrozenUserToggleAction::ReportUnavailable => { + warn!( + "Frozen mode unavailable: no direct capture backend and no screenshot portal backend; ignoring toggle" + ); state.input_state.push_toast( ToastPriority::Info, "capture", - Toast::warning("Freeze is already preparing another overlay operation."), + Toast::warning("Freeze is unavailable because screen capture is not available."), ); - return; } - if let Err(err) = state.frozen.start_capture() { - warn!("Frozen capture failed to start: {}", err); + FrozenUserToggleAction::RequestUserFreeze => { + let _ = state.request_screen_acquisition(ScreenAcquisitionOwner::UserFreeze); + } + } + + let record = if decision.user_action == FrozenUserToggleAction::RequestUserFreeze { + state.queued_screen_acquisition() + } else { + decision.queued_to_start + }; + let Some(record) = record else { + return; + }; + if !state.enter_overlay_suppression(OverlaySuppression::Frozen) { + warn!("Frozen mode requested while overlay is suppressed; ignoring toggle"); + state.complete_queued_acquisition( + record.id, + record.owner, + ScreenAcquisitionOutcome::Unavailable, + ); + return; + } + match state.frozen.start_capture_for(record.id, record.owner) { + Ok(()) => { + debug_assert!(state.mark_screen_acquisition_started(record.id, record.owner)); + } + Err(err) => { + warn!("Frozen capture failed to start: {err}"); state.exit_overlay_suppression(OverlaySuppression::Frozen); - state.frozen.cancel(&mut state.input_state); + state.complete_queued_acquisition( + record.id, + record.owner, + ScreenAcquisitionOutcome::Unavailable, + ); } } } @@ -230,9 +323,8 @@ fn handle_capture_results(state: &mut WaylandState) { // Restore overlay. state.show_overlay(); - state.capture.clear_in_progress(); - - let exit_after_capture = state.capture.take_exit_on_success(); + let exit_after_capture = state.capture.exit_on_success(); + state.capture.finish_capture_lifecycle(); let mut should_exit = false; match outcome { @@ -426,11 +518,9 @@ fn handle_capture_manager_failure( operation: Option, error: &str, ) { - state.capture.clear_preflight(); state.capture.clear_pending_pdf_export(); state.show_overlay(); - state.capture.clear_in_progress(); - state.capture.clear_exit_on_success(); + state.capture.finish_capture_lifecycle(); let message = match operation { Some(ImageOperationKind::Screenshot) => friendly_capture_error(error), @@ -456,3 +546,73 @@ fn handle_capture_manager_failure( Some("dialog-error".to_string()), ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; + + fn record( + owner: ScreenAcquisitionOwner, + stage: AcquisitionStage, + ) -> crate::backend::wayland::acquisition::AcquisitionRecord { + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry.request(owner).expect("test acquisition"); + if stage == AcquisitionStage::Started { + assert!(registry.mark_started(id, owner)); + } + registry.take().expect("test record") + } + + #[test] + fn queued_modal_absorbs_same_batch_user_toggle_then_still_drains_modal() { + for owner in [ + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ] { + let modal = record(owner, AcquisitionStage::Queued); + + let decision = frozen_toggle_pass_decision(true, Some(modal), false, true); + + assert_eq!( + decision.user_action, + FrozenUserToggleAction::AbsorbQueuedModal + ); + assert_eq!(decision.queued_to_start, Some(modal)); + } + } + + #[test] + fn started_modal_ignores_same_batch_user_toggle_without_starting_another_capture() { + let modal = record(ScreenAcquisitionOwner::Ocr, AcquisitionStage::Started); + + let decision = frozen_toggle_pass_decision(true, Some(modal), false, true); + + assert_eq!( + decision.user_action, + FrozenUserToggleAction::IgnoreInProgress + ); + assert_eq!(decision.queued_to_start, None); + } + + #[test] + fn queued_acquisition_drains_with_or_without_a_user_toggle() { + let modal = record(ScreenAcquisitionOwner::Ocr, AcquisitionStage::Queued); + let user = record(ScreenAcquisitionOwner::UserFreeze, AcquisitionStage::Queued); + + assert_eq!( + frozen_toggle_pass_decision(false, Some(modal), false, true), + FrozenTogglePassDecision { + user_action: FrozenUserToggleAction::None, + queued_to_start: Some(modal), + } + ); + assert_eq!( + frozen_toggle_pass_decision(true, Some(user), false, true), + FrozenTogglePassDecision { + user_action: FrozenUserToggleAction::IgnoreInProgress, + queued_to_start: Some(user), + } + ); + } +} diff --git a/src/backend/wayland/capture.rs b/src/backend/wayland/capture.rs index 1c35df29f..7012c412b 100644 --- a/src/backend/wayland/capture.rs +++ b/src/backend/wayland/capture.rs @@ -135,7 +135,7 @@ impl CaptureState { } /// Clear any pending preflight capture request. - pub fn clear_preflight(&mut self) { + fn clear_preflight(&mut self) { self.preflight = CapturePreflight::None; self.pending_request = None; } @@ -157,10 +157,15 @@ impl CaptureState { self.in_progress = true; } - /// Marks capture as finished. - pub fn clear_in_progress(&mut self) { + /// Finishes the current capture lifecycle and clears all reusable state. + /// + /// Region-capture and board-delivery lifecycle fields are intentionally + /// absent until their later implementation phases. + pub fn finish_capture_lifecycle(&mut self) { self.in_progress = false; self.accepted_id = None; + self.exit_on_success = false; + self.clear_preflight(); } /// Records the manager identity accepted for the current lifecycle. @@ -190,16 +195,9 @@ impl CaptureState { self.exit_on_success = value; } - /// Clears any pending exit request for the current capture. - pub fn clear_exit_on_success(&mut self) { - self.exit_on_success = false; - } - - /// Returns and clears the exit-on-success flag for the last capture. - pub fn take_exit_on_success(&mut self) -> bool { - let value = self.exit_on_success; - self.exit_on_success = false; - value + /// Returns whether the current capture should exit on success. + pub fn exit_on_success(&self) -> bool { + self.exit_on_success } } @@ -208,6 +206,14 @@ mod tests { use super::*; use crate::capture::types::{CaptureDestination, CaptureType}; + fn screenshot_request() -> CapturePreflightRequest { + CapturePreflightRequest::Screenshot(CaptureRequest { + capture_type: CaptureType::FullScreen, + destination: CaptureDestination::ClipboardOnly, + save_config: None, + }) + } + #[test] fn preflight_waits_for_render_before_request() { let manager = CaptureManager::with_closed_channel_for_test(); @@ -247,11 +253,52 @@ mod tests { assert_eq!(state.accepted_id(), None); assert!(state.record_accepted(other)); - state.clear_in_progress(); + state.finish_capture_lifecycle(); assert_eq!(state.accepted_id(), None); assert!(!state.is_in_progress()); } + #[test] + fn lifecycle_finish_cancels_awaiting_render_and_clears_the_pending_request() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + let accepted = CaptureRequestId::for_test(9); + + state.mark_in_progress(); + state.set_exit_on_success(true); + assert!(state.record_accepted(accepted)); + state.queue_preflight(screenshot_request()); + + state.finish_capture_lifecycle(); + + assert!(!state.is_in_progress()); + assert_eq!(state.accepted_id(), None); + assert!(!state.exit_on_success()); + assert!(!state.preflight_pending()); + assert!(state.take_preflight_request().is_none()); + } + + #[test] + fn lifecycle_finish_cancels_awaiting_frame_and_clears_the_pending_request() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + let accepted = CaptureRequestId::for_test(10); + + state.mark_in_progress(); + state.set_exit_on_success(true); + assert!(state.record_accepted(accepted)); + state.queue_preflight(screenshot_request()); + state.mark_preflight_rendered(); + + state.finish_capture_lifecycle(); + + assert!(!state.is_in_progress()); + assert_eq!(state.accepted_id(), None); + assert!(!state.exit_on_success()); + assert!(!state.preflight_pending()); + assert!(state.take_preflight_request().is_none()); + } + #[test] fn capture_layout_context_rejects_output_or_geometry_generation_changes() { let context = CaptureLayoutContext::new(7, 3); diff --git a/src/backend/wayland/clipboard/file_list.rs b/src/backend/wayland/clipboard/file_list.rs index 592283d40..29434a556 100644 --- a/src/backend/wayland/clipboard/file_list.rs +++ b/src/backend/wayland/clipboard/file_list.rs @@ -1,8 +1,9 @@ use super::{ - CLIPBOARD_READ_TIMEOUT, ClipboardPasteResult, ClipboardReadError, MAX_CLIPBOARD_IMAGE_BYTES, + CLIPBOARD_READ_TIMEOUT, ClipboardPasteResult, ClipboardReadError, image::decode_clipboard_image, system::read_pipe_with_timeout, }; use crate::file_uri; +use crate::screen_pixels::EmbeddedImageLimits; #[cfg(unix)] use std::fs::OpenOptions; use std::fs::{self, File}; @@ -97,7 +98,11 @@ fn parse_clipboard_file_uris(mime_type: &str, bytes: &[u8]) -> Result Result, ClipboardReadError> { ensure_regular_clipboard_path(path)?; let file = open_regular_clipboard_file(path)?; - read_pipe_with_timeout(file, MAX_CLIPBOARD_IMAGE_BYTES, CLIPBOARD_READ_TIMEOUT) + read_pipe_with_timeout( + file, + EmbeddedImageLimits::default().max_bytes(), + CLIPBOARD_READ_TIMEOUT, + ) } fn map_clipboard_file_read_error(path: &Path, err: ClipboardReadError) -> ClipboardPasteResult { @@ -142,9 +147,13 @@ fn validate_clipboard_file_metadata( path.display() ))); } - if metadata.len() > MAX_CLIPBOARD_IMAGE_BYTES as u64 { + let limits = EmbeddedImageLimits::default(); + if usize::try_from(metadata.len()) + .ok() + .is_none_or(|byte_len| !limits.allows_bytes(byte_len)) + { return Err(ClipboardReadError::TooLarge { - limit: MAX_CLIPBOARD_IMAGE_BYTES, + limit: limits.max_bytes(), }); } Ok(()) diff --git a/src/backend/wayland/clipboard/image.rs b/src/backend/wayland/clipboard/image.rs index 3e5085085..2d2e188d7 100644 --- a/src/backend/wayland/clipboard/image.rs +++ b/src/backend/wayland/clipboard/image.rs @@ -1,10 +1,9 @@ -use super::{ - ClipboardPasteResult, MAX_CLIPBOARD_IMAGE_PIXELS, WAYSCRIBER_SELECTION_MIME, file_list, -}; +use super::{ClipboardPasteResult, WAYSCRIBER_SELECTION_MIME, file_list}; use crate::draw::EmbeddedImage; use crate::image_decode::{ EncodedImageFormat, decode_rgba, format_from_mime_or_bytes, image_dimensions, }; +use crate::screen_pixels::EmbeddedImageLimits; pub(super) fn choose_supported_mime(offered: &[String]) -> Option { if let Some(mime) = [ @@ -35,12 +34,12 @@ pub(super) fn decode_clipboard_image(mime_type: &str, bytes: Vec) -> Clipboa Ok(dimensions) => dimensions, Err(err) => return ClipboardPasteResult::DecodeFailed(err), }; - let pixels = dimensions.0 as u64 * dimensions.1 as u64; - if pixels > MAX_CLIPBOARD_IMAGE_PIXELS { + let limits = EmbeddedImageLimits::default(); + if !limits.allows_pixels(dimensions.0, dimensions.1) { return ClipboardPasteResult::TooManyPixels { width: dimensions.0, height: dimensions.1, - limit: MAX_CLIPBOARD_IMAGE_PIXELS, + limit: limits.max_pixels(), }; } if let Err(err) = decode_rgba(format, &bytes) { @@ -71,11 +70,11 @@ fn canonical_image_mime_type(format: EncodedImageFormat) -> &'static str { #[cfg(test)] mod tests { - use crate::backend::wayland::clipboard::MAX_CLIPBOARD_IMAGE_BYTES; + use crate::screen_pixels::EmbeddedImageLimits; #[test] fn image_byte_cap_leaves_room_for_default_persisted_create_history() { - let encoded_len = MAX_CLIPBOARD_IMAGE_BYTES.div_ceil(3) * 4; + let encoded_len = EmbeddedImageLimits::default().max_bytes().div_ceil(3) * 4; let duplicated_history_len = encoded_len * 2; let default_session_budget = 50 * 1024 * 1024; let json_margin = 512 * 1024; diff --git a/src/backend/wayland/clipboard/mod.rs b/src/backend/wayland/clipboard/mod.rs index 888d18214..aecd8584a 100644 --- a/src/backend/wayland/clipboard/mod.rs +++ b/src/backend/wayland/clipboard/mod.rs @@ -19,9 +19,7 @@ pub(super) const WAYSCRIBER_SELECTION_MIME: &str = "application/vnd.wayscriber.s // A pasted image is persisted in the visible frame and in the Create undo action. // Keep one accepted image comfortably below the default 50 MiB session JSON budget. -pub(super) const MAX_CLIPBOARD_IMAGE_BYTES: usize = 3 * 1024 * 1024; pub(super) const MAX_CLIPBOARD_SELECTION_BYTES: usize = 2 * 1024 * 1024; -pub(super) const MAX_CLIPBOARD_IMAGE_PIXELS: u64 = 48_000_000; pub(super) const CLIPBOARD_READ_TIMEOUT: Duration = Duration::from_millis(1500); pub(super) const CLIPBOARD_FINGERPRINT_BYTES: usize = 4096; pub(super) const CLIPBOARD_FINGERPRINT_TIMEOUT: Duration = Duration::from_millis(300); diff --git a/src/backend/wayland/clipboard/transfer.rs b/src/backend/wayland/clipboard/transfer.rs index 2872e498c..a85094da3 100644 --- a/src/backend/wayland/clipboard/transfer.rs +++ b/src/backend/wayland/clipboard/transfer.rs @@ -1,12 +1,13 @@ use super::{ CLIPBOARD_READ_TIMEOUT, ClipboardPasteCompletion, ClipboardPasteResult, - ClipboardPublishCompletion, ClipboardReadError, MAX_CLIPBOARD_IMAGE_BYTES, - MAX_CLIPBOARD_SELECTION_BYTES, WAYSCRIBER_SELECTION_MIME, file_list, image, system, + ClipboardPublishCompletion, ClipboardReadError, MAX_CLIPBOARD_SELECTION_BYTES, + WAYSCRIBER_SELECTION_MIME, file_list, image, system, }; use crate::draw::{EmbeddedImage, Shape}; use crate::input::state::{ ClipboardFingerprint, ClipboardPasteRequest, WayscriberClipboardSelection, }; +use crate::screen_pixels::EmbeddedImageLimits; #[derive(Debug, Clone)] pub(in crate::backend::wayland) struct FailedLocalSelectionProbe { @@ -153,7 +154,7 @@ pub(in crate::backend::wayland) fn resolve_system_clipboard() -> ClipboardPasteR let limit = if mime_type == WAYSCRIBER_SELECTION_MIME { MAX_CLIPBOARD_SELECTION_BYTES } else { - MAX_CLIPBOARD_IMAGE_BYTES + EmbeddedImageLimits::default().max_bytes() }; let bytes = match system::read_clipboard_mime(&mime_type, limit, CLIPBOARD_READ_TIMEOUT) { diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index 616b64c20..f049158c6 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -21,6 +21,7 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{ }; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; +use crate::backend::wayland::acquisition::ScreenAcquisitionOutcome; use crate::backend::wayland::capture::CaptureLayoutContext; use crate::backend::wayland::frozen_geometry::require_verified_capture_source; use crate::input::InputState; @@ -431,6 +432,10 @@ impl FrozenState { } pub(super) fn finish_stale_direct_capture(&mut self, input_state: &mut InputState) { + if self.has_acquisition_attempt() { + self.finish_acquisition(ScreenAcquisitionOutcome::StaleLayout, input_state); + return; + } self.capture_done = true; input_state.set_frozen_active(false); input_state.needs_redraw = true; diff --git a/src/backend/wayland/frozen/mod.rs b/src/backend/wayland/frozen/mod.rs index e7d3bd920..0d0684173 100644 --- a/src/backend/wayland/frozen/mod.rs +++ b/src/backend/wayland/frozen/mod.rs @@ -4,6 +4,37 @@ mod image; mod portal; mod state; +use wayland_client::protocol::wl_output; + +/// Capture-time output identity retained beside an installed screen image. +/// +/// This deliberately does not read mutable live output state. A selector token +/// must describe the output that produced the pixels, even if output metadata +/// changes before the selector is armed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ScreenImageProvenance { + pub output_id: u32, + pub output_layout_generation: u64, + pub output_scale: i32, + pub output_transform: wl_output::Transform, +} + +impl ScreenImageProvenance { + pub fn new( + output_id: u32, + output_layout_generation: u64, + output_scale: i32, + output_transform: wl_output::Transform, + ) -> Option { + (output_scale > 0).then_some(Self { + output_id, + output_layout_generation, + output_scale, + output_transform, + }) + } +} + pub(in crate::backend::wayland) use ext_image_copy::ExtImageCopyManagers; pub use image::FrozenImage; pub(in crate::backend::wayland) use image::{copy_shm_argb, validate_shm_buffer_layout}; diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index 5025230a2..a404f30a1 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -3,6 +3,7 @@ use anyhow::Result; use log::warn; use std::time::{Duration, Instant}; +use crate::backend::wayland::acquisition::ScreenAcquisitionOutcome; use crate::backend::wayland::frozen::FrozenImage; use crate::backend::wayland::frozen_geometry::require_verified_capture_source; use crate::backend::wayland::portal_capture::{ @@ -84,14 +85,23 @@ impl FrozenState { .is_some_and(|task| task.timed_out(now)) { warn!("Portal frozen capture timed out; restoring overlay"); - input_state.push_toast( - ToastPriority::Critical, - "freeze", - Toast::error("Freeze timed out while waiting for screen capture."), - ); - input_state.set_frozen_active(false); - self.finish_portal_task(); - self.capture_done = true; + if self.has_acquisition_attempt() { + self.finish_acquisition( + ScreenAcquisitionOutcome::Failed( + "Freeze timed out while waiting for screen capture.".to_string(), + ), + input_state, + ); + } else { + input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error("Freeze timed out while waiting for screen capture."), + ); + input_state.set_frozen_active(false); + self.finish_portal_task(); + self.capture_done = true; + } return; } @@ -113,48 +123,86 @@ impl FrozenState { } else { warn!("Portal capture for inactive output discarded"); } - Self::push_stale_layout_toast(input_state); - self.capture_done = true; + if self.has_acquisition_attempt() { + self.finish_acquisition(ScreenAcquisitionOutcome::StaleLayout, input_state); + } else { + Self::push_stale_layout_toast(input_state); + self.capture_done = true; + } } self.finish_portal_task(); } PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { log::info!("Portal frozen capture cancelled: {reason}"); - input_state.set_frozen_active(false); - input_state.needs_redraw = true; - self.finish_portal_task(); - self.capture_done = true; + if self.has_acquisition_attempt() { + self.finish_acquisition(ScreenAcquisitionOutcome::Cancelled, input_state); + } else { + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + self.finish_portal_task(); + self.capture_done = true; + } } PortalPoll::Ready(Err(err)) => { warn!("Portal frozen capture failed: {err}"); - input_state.push_toast( - ToastPriority::Critical, - "freeze", - Toast::error("Freeze could not capture the screen."), - ); - input_state.set_frozen_active(false); - self.finish_portal_task(); - self.capture_done = true; + if self.has_acquisition_attempt() { + self.finish_acquisition( + ScreenAcquisitionOutcome::Failed( + "Freeze could not capture the screen.".to_string(), + ), + input_state, + ); + } else { + input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error("Freeze could not capture the screen."), + ); + input_state.set_frozen_active(false); + self.finish_portal_task(); + self.capture_done = true; + } } PortalPoll::Failed(err) => { warn!("Portal frozen capture task failed: {err}"); - input_state.push_toast( - ToastPriority::Critical, - "freeze", - Toast::error("Freeze could not capture the screen."), - ); - input_state.set_frozen_active(false); - self.finish_portal_task(); - self.capture_done = true; + if self.has_acquisition_attempt() { + self.finish_acquisition( + ScreenAcquisitionOutcome::Failed( + "Freeze could not capture the screen.".to_string(), + ), + input_state, + ); + } else { + input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error("Freeze could not capture the screen."), + ); + input_state.set_frozen_active(false); + self.finish_portal_task(); + self.capture_done = true; + } } PortalPoll::Pending => {} PortalPoll::Disconnected => { warn!("Portal frozen capture channel disconnected"); - input_state.push_toast(ToastPriority::Critical, "freeze", Toast::error("Freeze could not capture the screen because the system capture service stopped responding.")); - input_state.set_frozen_active(false); - self.finish_portal_task(); - self.capture_done = true; + let message = "Freeze could not capture the screen because the system capture service stopped responding."; + if self.has_acquisition_attempt() { + self.finish_acquisition( + ScreenAcquisitionOutcome::Failed(message.to_string()), + input_state, + ); + } else { + input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error(message), + ); + input_state.set_frozen_active(false); + self.finish_portal_task(); + self.capture_done = true; + } } } } @@ -173,6 +221,9 @@ impl FrozenState { #[cfg(test)] mod tests { use super::*; + use crate::backend::wayland::acquisition::{ + ScreenAcquisitionOutcome, ScreenAcquisitionOwner, ScreenAcquisitionRegistry, + }; use crate::backend::wayland::frozen::FrozenState; use crate::backend::wayland::portal_task::PORTAL_CAPTURE_TIMEOUT; use crate::input::state::test_support::make_test_input_state; @@ -245,11 +296,12 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); + frozen.set_active_output(None, Some(1)); frozen.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((None, 0, Some(crop_geometry((0, 0))), image(0))) }, + async { Ok((Some(1), 0, Some(crop_geometry((0, 0))), image(0))) }, )); frozen.portal_in_progress = true; poll_until_finished(&mut frozen, &mut input).await?; @@ -354,6 +406,40 @@ mod tests { Ok(()) } + #[tokio::test] + async fn portal_cancellation_finishes_the_correlated_attempt() -> anyhow::Result<()> { + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry + .request(ScreenAcquisitionOwner::UserFreeze) + .expect("id"); + frozen.start_capture_for(id, ScreenAcquisitionOwner::UserFreeze)?; + frozen.portal_task = Some(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async { + Err(CaptureError::Cancelled( + "user closed the chooser".to_string(), + )) + }, + )); + frozen.portal_in_progress = true; + + poll_until_finished(&mut frozen, &mut input).await?; + + assert_eq!( + frozen + .take_acquisition_completion() + .map(|completion| completion.outcome), + Some(ScreenAcquisitionOutcome::Cancelled) + ); + assert!(frozen.take_capture_done()); + assert!(input.ui_toast.is_none()); + Ok(()) + } + #[tokio::test] async fn stale_output_is_discarded_without_mutating_current_frozen_state() -> anyhow::Result<()> { diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index 986af6c61..fd18d49f4 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -4,8 +4,12 @@ use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; use crate::backend::wayland::RuntimeWakeHandle; +use crate::backend::wayland::acquisition::{ + ScreenAcquisitionCompletion, ScreenAcquisitionId, ScreenAcquisitionOutcome, + ScreenAcquisitionOwner, +}; use crate::backend::wayland::capture::CaptureLayoutContext; -use crate::backend::wayland::frozen::FrozenImage; +use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; use crate::backend::wayland::frozen_geometry::OutputGeometry; use crate::backend::wayland::portal_capture::{crop_argb, layout_token_matches}; use crate::backend::wayland::portal_task::PortalTask; @@ -118,6 +122,7 @@ pub struct FrozenState { pub(super) output_layout_generation: u64, pub(super) direct_capture: Option, pub(super) image: Option, + image_provenance: Option, image_target_dimensions: Option<(u32, u32)>, image_generation: u64, pub(super) portal_task: Option>, @@ -130,6 +135,8 @@ pub struct FrozenState { preflight_layout_generation: Option, pub(super) capture_done: bool, pending_image: Option, + acquisition_attempt: Option<(ScreenAcquisitionId, ScreenAcquisitionOwner)>, + acquisition_completion: Option, } impl FrozenState { @@ -171,6 +178,7 @@ impl FrozenState { output_layout_generation: 0, direct_capture: None, image: None, + image_provenance: None, image_target_dimensions: None, image_generation: 0, portal_task: None, @@ -183,6 +191,8 @@ impl FrozenState { preflight_layout_generation: None, capture_done: false, pending_image: None, + acquisition_attempt: None, + acquisition_completion: None, } } @@ -217,6 +227,11 @@ impl FrozenState { self.output_layout_generation } + pub(in crate::backend::wayland) fn image_provenance(&self) -> Option { + self.image.as_ref()?; + self.image_provenance + } + pub fn image(&self) -> Option<&FrozenImage> { self.image.as_ref() } @@ -229,6 +244,19 @@ impl FrozenState { pub fn set_image(&mut self, image: FrozenImage) { self.image_target_dimensions = Some((image.width, image.height)); self.image = Some(image); + self.image_provenance = None; + self.bump_image_generation(); + } + + #[cfg(test)] + pub(in crate::backend::wayland) fn set_image_with_provenance_for_test( + &mut self, + image: FrozenImage, + provenance: ScreenImageProvenance, + ) { + self.image_target_dimensions = Some((image.width, image.height)); + self.image = Some(image); + self.image_provenance = Some(provenance); self.bump_image_generation(); } @@ -340,20 +368,156 @@ impl FrozenState { &mut self, input_state: &mut InputState, ) { - let message = self - .ensure_preflight_layout_current() - .err() + let stale_message = self.ensure_preflight_layout_current().err(); + let message = stale_message + .clone() .unwrap_or_else(|| "Freeze could not capture the screen.".to_string()); + if self.has_acquisition_attempt() { + let outcome = if stale_message.is_some() { + ScreenAcquisitionOutcome::StaleLayout + } else { + ScreenAcquisitionOutcome::Failed(message) + }; + self.finish_acquisition(outcome, input_state); + return; + } input_state.push_toast(ToastPriority::Critical, "freeze", Toast::error(message)); self.cancel(input_state); } + pub(in crate::backend::wayland) fn finish_preflight_failure( + &mut self, + message: String, + input_state: &mut InputState, + ) { + let outcome = if self.ensure_preflight_layout_current().is_err() { + ScreenAcquisitionOutcome::StaleLayout + } else { + ScreenAcquisitionOutcome::Failed(message) + }; + self.finish_acquisition(outcome, input_state); + } + pub fn take_capture_done(&mut self) -> bool { let done = self.capture_done; self.capture_done = false; done } + pub(in crate::backend::wayland) fn start_capture_for( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> anyhow::Result<()> { + anyhow::ensure!( + self.acquisition_attempt.is_none() && self.acquisition_completion.is_none(), + "another screen acquisition is still pending" + ); + anyhow::ensure!( + !self.is_in_progress(), + "another frozen capture is already in progress" + ); + self.start_capture()?; + self.acquisition_attempt = Some((id, owner)); + Ok(()) + } + + fn finish_attempt_resources(&mut self) { + if let Some(capture) = self.direct_capture.take() { + capture.destroy(); + } + self.preflight_pending = false; + self.preflight_backend = None; + self.preflight_output_id = None; + self.preflight_layout_generation = None; + self.portal_in_progress = false; + if let Some(mut task) = self.portal_task.take() { + task.cancel(); + } + self.portal_target_output_id = None; + self.pending_image = None; + self.capture_done = true; + } + + pub(in crate::backend::wayland) fn finish_ready_acquisition( + &mut self, + input_state: &mut InputState, + ) { + debug_assert!(self.image().is_some()); + debug_assert!(input_state.frozen_active()); + self.finish_acquisition( + ScreenAcquisitionOutcome::Ready { + installed_generation: self.image_generation(), + }, + input_state, + ); + } + + pub(in crate::backend::wayland) fn finish_acquisition( + &mut self, + outcome: ScreenAcquisitionOutcome, + input_state: &mut InputState, + ) { + let Some((id, owner)) = self.acquisition_attempt.take() else { + return; + }; + debug_assert!(self.acquisition_completion.is_none()); + self.finish_attempt_resources(); + if !matches!(outcome, ScreenAcquisitionOutcome::Ready { .. }) { + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + } + self.acquisition_completion = Some(ScreenAcquisitionCompletion { id, owner, outcome }); + } + + pub(in crate::backend::wayland) fn abandon_acquisition( + &mut self, + input_state: &mut InputState, + ) { + self.acquisition_attempt = None; + self.finish_attempt_resources(); + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + } + + #[allow(dead_code)] // Used by public modal-owner cancellation adapters. + pub(in crate::backend::wayland) fn acquisition_completion( + &self, + ) -> Option<&ScreenAcquisitionCompletion> { + self.acquisition_completion.as_ref() + } + + pub(in crate::backend::wayland) fn has_acquisition_attempt(&self) -> bool { + self.acquisition_attempt.is_some() + } + + pub(in crate::backend::wayland) fn take_acquisition_completion( + &mut self, + ) -> Option { + self.acquisition_completion.take() + } + + #[allow(dead_code)] // Used by public modal-owner cancellation adapters. + pub(in crate::backend::wayland) fn take_matching_acquisition_completion( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + if !self + .acquisition_completion + .as_ref() + .is_some_and(|completion| completion.id == id && completion.owner == owner) + { + return None; + } + self.acquisition_completion.take() + } + + #[cfg(test)] + fn attempt(&self) -> Option<(ScreenAcquisitionId, ScreenAcquisitionOwner)> { + self.acquisition_attempt + } + pub(in crate::backend::wayland) fn direct_capture_timeout( &self, now: Instant, @@ -409,6 +573,7 @@ impl FrozenState { ); } let mut image = pending.image; + let mut provenance = None; if matches!(pending.source, FrozenCaptureSource::ActiveOutput) { let Some(geometry) = pending.source_geometry.as_ref() else { @@ -416,6 +581,14 @@ impl FrozenState { .reject_pending_image(input_state, "Freeze capture geometry is unavailable"); }; let output_transform = pending.output_transform.unwrap_or(geometry.transform); + provenance = pending.target_output_id.and_then(|output_id| { + ScreenImageProvenance::new( + output_id, + pending.layout_generation, + geometry.scale, + output_transform, + ) + }); image = match image.with_output_transform(output_transform) { Ok(image) => image, Err(error) => { @@ -437,7 +610,6 @@ impl FrozenState { let Some(geometry) = pending .source_geometry .as_ref() - .or(self.active_geometry.as_ref()) .cloned() .and_then(|geometry| geometry.with_revalidated_output_count(live_output_count)) else { @@ -446,6 +618,14 @@ impl FrozenState { "Freeze failed after the output layout changed", ); }; + provenance = pending.target_output_id.and_then(|output_id| { + ScreenImageProvenance::new( + output_id, + pending.layout_generation, + geometry.scale, + geometry.transform, + ) + }); let Some((capture_width, capture_height)) = geometry.verified_pixel_size() else { return self.reject_pending_image( input_state, @@ -463,6 +643,13 @@ impl FrozenState { image = cropped; } + let Some(provenance) = provenance else { + return self.reject_pending_image( + input_state, + "Freeze capture source identity is unavailable", + ); + }; + if !OutputGeometry::dimensions_have_compatible_aspect( (image.width, image.height), (phys_width, phys_height), @@ -475,10 +662,14 @@ impl FrozenState { self.image_target_dimensions = Some((phys_width, phys_height)); self.image = Some(image); + self.image_provenance = Some(provenance); self.bump_image_generation(); input_state.set_frozen_active(true); input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; + self.finish_ready_acquisition(input_state); + // Legacy tests and late callbacks without an acquisition still use the + // capture-done wakeup as their resource-restoration boundary. self.capture_done = true; Ok(true) } @@ -488,10 +679,12 @@ impl FrozenState { input_state: &mut InputState, error: impl Into, ) -> Result { + let error = error.into(); + self.finish_acquisition(ScreenAcquisitionOutcome::Failed(error.clone()), input_state); self.capture_done = true; input_state.set_frozen_active(false); input_state.needs_redraw = true; - Err(error.into()) + Err(error) } fn crop_pending_image( @@ -551,26 +744,12 @@ impl FrozenState { } pub fn cancel(&mut self, input_state: &mut InputState) { - if let Some(capture) = self.direct_capture.take() { - capture.destroy(); - } - self.preflight_pending = false; - self.preflight_backend = None; - self.preflight_output_id = None; - self.preflight_layout_generation = None; - self.portal_in_progress = false; - if let Some(mut task) = self.portal_task.take() { - task.cancel(); - } - self.portal_target_output_id = None; - self.pending_image = None; - self.capture_done = true; - input_state.set_frozen_active(false); - input_state.needs_redraw = true; + self.abandon_acquisition(input_state); } fn clear_image(&mut self) -> bool { let had_image = self.image.take().is_some(); + self.image_provenance = None; self.image_target_dimensions = None; if had_image { self.bump_image_generation(); @@ -620,6 +799,10 @@ fn next_capture_backend_after( #[cfg(test)] mod tests { use super::*; + use crate::backend::wayland::acquisition::{ + ScreenAcquisitionCompletion, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, + ScreenAcquisitionRegistry, + }; use crate::input::state::test_support::make_test_input_state; fn verified_output_geometry( @@ -942,6 +1125,7 @@ mod tests { fn desktop_capture_crops_from_screenshot_origin_not_logical_times_scale() { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(7)); let mut data = Vec::new(); for pixel in 0u8..10 { data.extend_from_slice(&[pixel, pixel, pixel, 255]); @@ -956,7 +1140,7 @@ mod tests { stride: 40, data, }, - None, + Some(7), Some(geometry), ); @@ -965,6 +1149,10 @@ mod tests { .expect("mixed-scale screenshot origin should crop the active output"); let image = state.image().expect("the frozen image should be active"); + assert_eq!( + state.image_provenance(), + ScreenImageProvenance::new(7, 0, 1, wl_output::Transform::Normal) + ); assert_eq!( image.data, vec![6, 6, 6, 255, 7, 7, 7, 255, 8, 8, 8, 255, 9, 9, 9, 255] @@ -975,6 +1163,7 @@ mod tests { fn desktop_capture_keeps_fractional_output_pixels_for_a_larger_overlay_buffer() { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(7)); let mut geometry = desktop_geometry(0, 0, 3, 2, 2, Some((0, 0))); geometry.pixel_size = Some((5, 3)); state.set_pending_desktop_image( @@ -984,7 +1173,7 @@ mod tests { stride: 20, data: vec![7; 5 * 3 * 4], }, - None, + Some(7), Some(geometry), ); @@ -1070,6 +1259,7 @@ mod tests { fn desktop_capture_crops_at_buffer_origin_for_a_proven_single_output() { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(7)); let geometry = desktop_geometry(10, 20, 2, 1, 2, None).with_known_output_count(Some(1)); assert_eq!(geometry.portal_crop_origin(4, 2), Some((0, 0))); @@ -1080,7 +1270,7 @@ mod tests { stride: 16, data: vec![7; 4 * 2 * 4], }, - None, + Some(7), Some(geometry), ); @@ -1093,6 +1283,32 @@ mod tests { assert!(input_state.frozen_active()); } + #[test] + fn desktop_capture_refuses_activation_without_capture_time_output_identity() { + let mut state = FrozenState::new(None); + let mut input_state = make_test_input_state(); + let geometry = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); + state.set_pending_desktop_image( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![7; 8], + }, + None, + Some(geometry), + ); + + let error = state + .activate_pending_image(2, 1, &mut input_state) + .expect_err("missing capture-time output identity must fail closed"); + + assert_eq!(error, "Freeze capture source identity is unavailable"); + assert!(state.image().is_none()); + assert!(state.image_provenance().is_none()); + assert!(!input_state.frozen_active()); + } + #[test] fn desktop_capture_rejects_a_stale_single_output_snapshot_when_live_count_grows() { let mut state = FrozenState::new(None); @@ -1266,4 +1482,362 @@ mod tests { assert!(!state.is_in_progress()); assert!(state.take_capture_done()); } + + #[test] + fn acquisition_terminal_consumes_attempt_once_and_retains_old_image_on_failure() { + let mut state = FrozenState::new_inner(None, None, true, None); + let mut input_state = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry.request(ScreenAcquisitionOwner::Ocr).expect("id"); + state.set_image(FrozenImage { + width: 1, + height: 1, + stride: 4, + data: vec![0; 4], + }); + input_state.set_frozen_active(true); + + state + .start_capture_for(id, ScreenAcquisitionOwner::Ocr) + .expect("capture starts"); + state.finish_acquisition( + ScreenAcquisitionOutcome::Failed("capture failed".to_string()), + &mut input_state, + ); + state.finish_acquisition(ScreenAcquisitionOutcome::Cancelled, &mut input_state); + + assert_eq!(state.attempt(), None); + assert!(state.image().is_some()); + assert!(!input_state.frozen_active()); + assert!(state.take_capture_done()); + let completion = state.take_acquisition_completion().expect("one completion"); + assert_eq!(completion.id, id); + assert_eq!(completion.owner, ScreenAcquisitionOwner::Ocr); + assert_eq!( + completion.outcome, + ScreenAcquisitionOutcome::Failed("capture failed".to_string()) + ); + assert_eq!(state.take_acquisition_completion(), None); + } + + #[derive(Clone, Copy, Debug)] + enum PendingImageRejectionFixture { + LayoutMismatch, + MissingActiveOutputGeometry, + MalformedTransformBuffer, + ActiveOutputSizeMismatch, + MissingDesktopGeometry, + StaleDesktopGeometry, + MissingVerifiedPixelSize, + DesktopCropFailure, + MissingOutputIdentity, + AspectMismatch, + } + + impl PendingImageRejectionFixture { + const ALL: [Self; 10] = [ + Self::LayoutMismatch, + Self::MissingActiveOutputGeometry, + Self::MalformedTransformBuffer, + Self::ActiveOutputSizeMismatch, + Self::MissingDesktopGeometry, + Self::StaleDesktopGeometry, + Self::MissingVerifiedPixelSize, + Self::DesktopCropFailure, + Self::MissingOutputIdentity, + Self::AspectMismatch, + ]; + + fn expected_error(self) -> &'static str { + match self { + Self::LayoutMismatch => "Freeze failed after the display layout changed", + Self::MissingActiveOutputGeometry => "Freeze capture geometry is unavailable", + Self::MalformedTransformBuffer => "Freeze capture transform failed:", + Self::ActiveOutputSizeMismatch => { + "Freeze capture dimensions do not match the active output" + } + Self::MissingDesktopGeometry | Self::StaleDesktopGeometry => { + "Freeze failed after the output layout changed" + } + Self::MissingVerifiedPixelSize | Self::DesktopCropFailure => { + "Freeze failed after the display changed size" + } + Self::MissingOutputIdentity => "Freeze capture source identity is unavailable", + Self::AspectMismatch => "Freeze capture aspect does not match the overlay surface", + } + } + + fn active_output_id(self) -> Option { + match self { + Self::StaleDesktopGeometry | Self::MissingOutputIdentity => None, + _ => Some(7), + } + } + + fn install_pending(self, state: &mut FrozenState) -> ((u32, u32), Option) { + let image = |width, height, stride, data_len| FrozenImage { + width, + height, + stride, + data: vec![2; data_len], + }; + let output_geometry = + || verified_output_geometry((2, 1), 1, wl_output::Transform::Normal, (2, 1)); + + match self { + Self::LayoutMismatch => { + state.set_pending_output_image(image(2, 1, 8, 8), 7, output_geometry()); + state.set_active_output(None, Some(8)); + ((2, 1), None) + } + Self::MissingActiveOutputGeometry => { + state.set_pending_output_image(image(2, 1, 8, 8), 7, output_geometry()); + state + .pending_image + .as_mut() + .expect("pending image installed through the production setter") + .source_geometry = None; + ((2, 1), None) + } + Self::MalformedTransformBuffer => { + state.set_pending_output_image_with_transform( + image(2, 2, 8, 12), + 7, + verified_output_geometry((2, 2), 1, wl_output::Transform::Normal, (2, 2)), + Some(wl_output::Transform::_90), + ); + ((2, 2), None) + } + Self::ActiveOutputSizeMismatch => { + state.set_pending_output_image(image(3, 1, 12, 12), 7, output_geometry()); + ((2, 1), None) + } + Self::MissingDesktopGeometry => { + state.set_pending_desktop_image(image(2, 1, 8, 8), Some(7), None); + ((2, 1), None) + } + Self::StaleDesktopGeometry => { + state.set_pending_desktop_image( + image(2, 1, 8, 8), + None, + Some( + desktop_geometry(0, 0, 2, 1, 1, None).with_known_output_count(Some(1)), + ), + ); + ((2, 1), Some(2)) + } + Self::MissingVerifiedPixelSize => { + let mut geometry = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); + geometry.pixel_size = None; + state.set_pending_desktop_image(image(2, 1, 8, 8), Some(7), Some(geometry)); + ((2, 1), None) + } + Self::DesktopCropFailure => { + state.set_pending_desktop_image( + image(2, 1, 8, 8), + Some(7), + Some(desktop_geometry(0, 0, 2, 1, 1, Some((3, 0)))), + ); + ((2, 1), None) + } + Self::MissingOutputIdentity => { + state.set_pending_desktop_image( + image(2, 1, 8, 8), + None, + Some(desktop_geometry(0, 0, 2, 1, 1, Some((0, 0)))), + ); + ((2, 1), None) + } + Self::AspectMismatch => { + state.set_pending_output_image( + image(10, 1, 40, 40), + 7, + verified_output_geometry((10, 1), 1, wl_output::Transform::Normal, (10, 1)), + ); + ((1, 10), None) + } + } + } + } + + #[test] + fn every_pending_image_rejection_finishes_its_acquisition_exactly_once() { + for fixture in PendingImageRejectionFixture::ALL { + let mut state = FrozenState::new_inner(None, None, true, None); + let mut input_state = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let owner = ScreenAcquisitionOwner::UserFreeze; + let id = registry.request(owner).expect("id"); + let retained_provenance = + ScreenImageProvenance::new(42, 9, 1, wl_output::Transform::Normal) + .expect("valid retained image provenance"); + state.set_image_with_provenance_for_test( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![1; 8], + }, + retained_provenance, + ); + let retained_generation = state.image_generation(); + input_state.set_frozen_active(true); + state.set_active_output(None, fixture.active_output_id()); + state + .start_capture_for(id, owner) + .expect("capture attempt starts"); + let ((phys_width, phys_height), live_output_count) = + fixture.install_pending(&mut state); + + let message = state + .activate_pending_image_with_live_outputs( + phys_width, + phys_height, + &mut input_state, + live_output_count, + ) + .expect_err("pending image fixture must be rejected"); + + if matches!( + fixture, + PendingImageRejectionFixture::MalformedTransformBuffer + ) { + assert!( + message.starts_with(fixture.expected_error()), + "{fixture:?} returned {message:?}" + ); + } else { + assert_eq!(message, fixture.expected_error(), "{fixture:?}"); + } + assert_eq!(state.attempt(), None, "{fixture:?}"); + assert!(!state.has_pending_image(), "{fixture:?}"); + assert!(!state.is_in_progress(), "{fixture:?}"); + assert_eq!(state.image_generation(), retained_generation, "{fixture:?}"); + assert_eq!( + state.image_provenance(), + Some(retained_provenance), + "{fixture:?}" + ); + let retained = state.image().expect("the old image remains installed"); + assert_eq!( + (retained.width, retained.height, retained.stride), + (2, 1, 8) + ); + assert_eq!(retained.data, vec![1; 8], "{fixture:?}"); + assert!(!input_state.frozen_active(), "{fixture:?}"); + assert!(state.take_capture_done(), "{fixture:?}"); + assert!(!state.take_capture_done(), "{fixture:?}"); + + assert_eq!( + state.take_matching_acquisition_completion(id, owner), + Some(ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Failed(message), + }), + "{fixture:?}" + ); + assert_eq!( + state.take_matching_acquisition_completion(id, owner), + None, + "{fixture:?} published more than one terminal" + ); + assert_eq!(state.take_acquisition_completion(), None, "{fixture:?}"); + } + } + + #[test] + fn undrained_terminal_is_taken_only_by_its_correlated_owner() { + let mut state = FrozenState::new_inner(None, None, true, None); + let mut input_state = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry.request(ScreenAcquisitionOwner::Ocr).expect("id"); + state + .start_capture_for(id, ScreenAcquisitionOwner::Ocr) + .expect("capture starts"); + state.finish_acquisition(ScreenAcquisitionOutcome::Cancelled, &mut input_state); + + assert_eq!( + state.take_matching_acquisition_completion(id, ScreenAcquisitionOwner::Eyedropper), + None + ); + assert!(state.acquisition_completion().is_some()); + assert_eq!( + state + .take_matching_acquisition_completion(id, ScreenAcquisitionOwner::Ocr) + .map(|completion| completion.outcome), + Some(ScreenAcquisitionOutcome::Cancelled) + ); + assert!(state.acquisition_completion().is_none()); + } + + #[test] + fn undrained_ready_terminal_transfers_its_exact_generation_once() { + let mut state = FrozenState::new_inner(None, None, true, None); + let mut input_state = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry + .request(ScreenAcquisitionOwner::Eyedropper) + .expect("id"); + state.set_image(FrozenImage { + width: 1, + height: 1, + stride: 4, + data: vec![7; 4], + }); + let generation = state.image_generation(); + input_state.set_frozen_active(true); + state + .start_capture_for(id, ScreenAcquisitionOwner::Eyedropper) + .expect("capture starts"); + + state.finish_ready_acquisition(&mut input_state); + + assert_eq!( + state.take_matching_acquisition_completion(id, ScreenAcquisitionOwner::Eyedropper,), + Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Eyedropper, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: generation, + }, + }) + ); + assert_eq!( + state.take_matching_acquisition_completion(id, ScreenAcquisitionOwner::Eyedropper,), + None, + "a second cancellation cannot consume or release the generation again" + ); + assert!(state.take_capture_done()); + assert!(!state.take_capture_done()); + assert!(input_state.frozen_active()); + } + + #[test] + fn preflight_layout_failure_is_classified_as_stale_layout() { + let mut state = FrozenState::new_inner(None, None, true, None); + let mut input_state = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry + .request(ScreenAcquisitionOwner::UserFreeze) + .expect("id"); + state + .start_capture_for(id, ScreenAcquisitionOwner::UserFreeze) + .expect("capture starts"); + state.set_active_geometry(Some(verified_output_geometry( + (1, 1), + 1, + wl_output::Transform::Normal, + (1, 1), + ))); + + state.finish_preflight_failure("backend refused".to_string(), &mut input_state); + + assert_eq!( + state + .take_acquisition_completion() + .map(|completion| completion.outcome), + Some(ScreenAcquisitionOutcome::StaleLayout) + ); + } } diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 88254160f..41cf8743d 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -159,7 +159,7 @@ impl KeyboardHandler for WaylandState { // Any fresh key press ends the previous auto-repeat; a repeatable one // re-arms it at the end of this handler. self.clear_key_repeat(); - if self.input_state.ocr_is_engaged() { + if self.input_state.region_is_engaged() { // Every other shortcut is swallowed while the selector is up so a // key cannot change the active tool mid-drag. if matches!(key, Key::Escape) @@ -265,7 +265,10 @@ impl KeyboardHandler for WaylandState { if self.key_repeat_key == Some(key) { self.clear_key_repeat(); } - if self.input_state.eyedropper_is_engaged() { + if screen_modal_swallows_key_release( + self.input_state.region_is_engaged(), + self.input_state.eyedropper_is_engaged(), + ) { return; } if matches!(key, Key::Space) && self.board_pan_key_held() { @@ -412,7 +415,7 @@ impl WaylandState { // exactly like a fresh press reports the first one. self.input_state .note_input_hud_key(key, self.input_state.modifiers); - if self.input_state.eyedropper_is_engaged() { + if self.input_state.region_is_engaged() || self.input_state.eyedropper_is_engaged() { return; } if matches!(key, Key::Space) && self.board_pan_key_held() { @@ -470,6 +473,13 @@ fn should_try_toolbar_key(key: Key, modal_capture_active: bool) -> bool { matches!(key, Key::Tab | Key::Return | Key::Space | Key::Escape) } +fn screen_modal_swallows_key_release( + region_selector_engaged: bool, + eyedropper_engaged: bool, +) -> bool { + region_selector_engaged || eyedropper_engaged +} + #[cfg(test)] mod tests { use super::*; @@ -481,6 +491,33 @@ mod tests { assert!(!should_try_toolbar_key(Key::Space, true)); } + #[test] + fn engaged_region_modal_swallows_shift_release_until_modifier_sync() { + assert!(screen_modal_swallows_key_release(true, false)); + assert!(screen_modal_swallows_key_release(false, true)); + assert!(!screen_modal_swallows_key_release(false, false)); + + let mut input = crate::input::state::test_support::make_test_input_state(); + input.sync_modifiers(true, false, false, false); + input.set_region_pending_capture( + crate::input::state::RegionPurposeTag::Ocr, + 1, + crate::input::state::ScreenCaptureSource::Frozen, + ); + if !screen_modal_swallows_key_release(input.region_is_engaged(), false) { + input.on_key_release(Key::Shift); + } + assert!( + input.modifiers.shift, + "key release must not outrun modifier sync" + ); + input.sync_modifiers(false, false, false, false); + assert!( + !input.modifiers.shift, + "compositor modifier sync is authoritative" + ); + } + #[test] fn toolbar_routing_only_allows_activate_and_tab_keys() { assert!(should_try_toolbar_key(Key::Tab, false)); diff --git a/src/backend/wayland/handlers/pointer/mod.rs b/src/backend/wayland/handlers/pointer/mod.rs index afdeea984..82d8e0237 100644 --- a/src/backend/wayland/handlers/pointer/mod.rs +++ b/src/backend/wayland/handlers/pointer/mod.rs @@ -4,7 +4,7 @@ use smithay_client_toolkit::seat::pointer::{PointerEvent, PointerEventKind, Poin use wayland_client::{Connection, QueueHandle, protocol::wl_pointer}; use crate::backend::wayland::state::{debug_toolbar_drag_logging_enabled, surface_id}; -use crate::input::state::OcrInputSource; +use crate::input::state::RegionInputSource; use super::super::state::WaylandState; diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 792f9adf8..c145327c3 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -16,7 +16,7 @@ impl WaylandState { on_toolbar: bool, inline_active: bool, ) { - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { let screen_position = if on_toolbar { self.toolbar_surface_screen_coords(&event.surface, event.position) } else { @@ -24,7 +24,7 @@ impl WaylandState { }; if let Some((x, y)) = screen_position { self.set_current_mouse(x.round() as i32, y.round() as i32); - self.update_ocr_selection(OcrInputSource::Pointer, x, y); + self.update_region_selection(RegionInputSource::Pointer, x, y); } self.update_pointer_cursor(on_toolbar || self.pointer_over_toolbar(), conn); return; diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 43d2e7e74..4ba2417a5 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -38,7 +38,7 @@ impl WaylandState { .clear_help_overlay_press_for(help_press_source); } - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { if on_toolbar || self.pointer_over_toolbar() { // A toolbar interaction ends the region first, then runs // normally; the click never lands on the selector. @@ -46,8 +46,8 @@ impl WaylandState { } else { match button { BTN_LEFT => { - self.begin_ocr_selection( - OcrInputSource::Pointer, + self.begin_region_selection( + RegionInputSource::Pointer, event.position.0, event.position.1, ); diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index f046559d1..e6e698476 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -23,7 +23,7 @@ impl WaylandState { return; } - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { if button == BTN_LEFT { let screen_position = if on_toolbar { self.toolbar_surface_screen_coords(&event.surface, event.position) @@ -31,7 +31,7 @@ impl WaylandState { Some(event.position) }; if let Some((x, y)) = screen_position { - self.finish_ocr_selection(OcrInputSource::Pointer, x, y); + self.finish_region_selection(RegionInputSource::Pointer, x, y); } } return; diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index e4ee3ac12..73ddfc676 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -171,9 +171,9 @@ impl WaylandState { .clear_help_overlay_press_for(HelpOverlayPressSource::Stylus); } - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { let (x, y) = self.current_stylus_position(); - self.begin_ocr_selection(crate::input::state::OcrInputSource::Stylus, x, y); + self.begin_region_selection(crate::input::state::RegionInputSource::Stylus, x, y); return; } @@ -331,21 +331,21 @@ mod tests { /// including while they are still waiting on a capture. #[test] fn screen_region_modals_block_stylus_barrel_actions() { - use crate::input::state::{EyedropperCaptureSource, OcrCaptureSource}; + use crate::input::state::{EyedropperCaptureSource, RegionPurposeTag, ScreenCaptureSource}; let mut state = make_test_input_state(); assert!(!modal_blocks_stylus_barrel_actions(&state)); - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 1, ScreenCaptureSource::Frozen); assert!(modal_blocks_stylus_barrel_actions(&state)); - state.activate_ocr(true); + state.activate_region(RegionPurposeTag::Ocr, 1); assert!(modal_blocks_stylus_barrel_actions(&state)); - state.cancel_ocr(); + state.cancel_region_ui_only(); assert!(!modal_blocks_stylus_barrel_actions(&state)); state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); assert!(modal_blocks_stylus_barrel_actions(&state)); - state.activate_eyedropper(true); + state.activate_eyedropper(Some(1)); assert!(modal_blocks_stylus_barrel_actions(&state)); state.cancel_eyedropper(); assert!(!modal_blocks_stylus_barrel_actions(&state)); diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index 1c276957e..09a5a6c0f 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -9,7 +9,7 @@ use crate::{ }; use crate::backend::wayland::state::WaylandState; -use crate::input::state::OcrInputSource; +use crate::input::state::RegionInputSource; const STYLUS_CURSOR_DAMAGE_RADIUS: i32 = 64; @@ -193,7 +193,7 @@ impl Dispatch for WaylandState { // and any OCR-owned freeze — alive until the user cancels by // hand. A region the mouse or a finger is dragging is not ours // to withdraw. - state.cancel_ocr_selection_from(OcrInputSource::Stylus); + state.cancel_region_selection_from(RegionInputSource::Stylus); // The pen is gone, so the tip-up the latch was waiting for is // never coming; leaving it armed would swallow a later contact. state.take_retired_stylus_contact(); @@ -243,12 +243,12 @@ impl Dispatch for WaylandState { if state.stylus_contact_retired { return; } - if state.input_state.ocr_is_active() { + if state.input_state.region_is_active() { if state.stylus_on_toolbar { state.cancel_ocr_for_toolbar_interaction(); } else if state.stylus_on_overlay { let (x, y) = state.current_or_pending_stylus_position(); - state.begin_ocr_selection(OcrInputSource::Stylus, x, y); + state.begin_region_selection(RegionInputSource::Stylus, x, y); return; } } @@ -295,17 +295,17 @@ impl Dispatch for WaylandState { // the tip-up that actually ends the disowned contact, whatever // else this Up does. let retired_contact = state.take_retired_stylus_contact(); - if state.input_state.ocr_is_active() { + if state.input_state.region_is_active() { if state.stylus_on_overlay { let (x, y) = state.current_or_pending_stylus_position(); // A no-op unless this pen owns the region, so a retired // contact lifting cannot submit one the mouse or a // finger is still drawing. - state.finish_ocr_selection(OcrInputSource::Stylus, x, y); + state.finish_region_selection(RegionInputSource::Stylus, x, y); } else { // The tip left the overlay before lifting; there is no // region of ours to submit, so withdraw only our own. - state.cancel_ocr_selection_from(OcrInputSource::Stylus); + state.cancel_region_selection_from(RegionInputSource::Stylus); } return; } @@ -330,13 +330,13 @@ impl Dispatch for WaylandState { state.queue_stylus_up(); } Event::Motion { x, y } => { - if state.input_state.ocr_is_active() && state.stylus_on_overlay { + if state.input_state.region_is_active() && state.stylus_on_overlay { state.stylus_last_pos = Some((x, y)); state.set_current_mouse(x.round() as i32, y.round() as i32); // Dropped unless this pen owns the region: a pen hovering // over the overlay, or one whose contact was retired, must // not drag somebody else's selection around. - state.update_ocr_selection(OcrInputSource::Stylus, x, y); + state.update_region_selection(RegionInputSource::Stylus, x, y); return; } if state.input_state.eyedropper_is_active() && state.stylus_on_overlay { @@ -465,28 +465,28 @@ mod tests { #[test] fn stylus_pressure_stops_at_the_selector_not_at_the_request() { use crate::input::state::test_support::make_test_input_state; - use crate::input::state::{EyedropperCaptureSource, OcrCaptureSource}; + use crate::input::state::{EyedropperCaptureSource, RegionPurposeTag, ScreenCaptureSource}; let fresh_contact = |state: &_| drop_stylus_pressure(true, false, state); let mut state = make_test_input_state(); assert!(!fresh_contact(&state)); - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 1, ScreenCaptureSource::Frozen); assert!( !fresh_contact(&state), "a stroke drawn while the capture is pending is still a real stroke" ); - state.activate_ocr(true); + state.activate_region(RegionPurposeTag::Ocr, 1); assert!(fresh_contact(&state)); - state.start_ocr_selection(OcrInputSource::Stylus, (10.0, 10.0)); + state.start_region_selection(RegionInputSource::Stylus, (10.0, 10.0)); assert!(fresh_contact(&state)); - state.cancel_ocr(); + state.cancel_region_ui_only(); assert!(!fresh_contact(&state)); state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); assert!(!fresh_contact(&state)); - state.activate_eyedropper(true); + state.activate_eyedropper(Some(1)); assert!(fresh_contact(&state)); state.cancel_eyedropper(); assert!(!fresh_contact(&state)); @@ -497,19 +497,19 @@ mod tests { /// pen again during the wait, but this contact is not the user drawing. #[test] fn a_retired_contact_never_reaches_the_tool_whatever_the_modal_is_doing() { - use crate::input::state::OcrCaptureSource; use crate::input::state::test_support::make_test_input_state; + use crate::input::state::{RegionPurposeTag, ScreenCaptureSource}; let mut state = make_test_input_state(); // The window the previous test allows, and the one that matters here. - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 1, ScreenCaptureSource::Frozen); assert!(!drop_stylus_pressure(true, false, &state)); assert!(drop_stylus_pressure(true, true, &state)); // And it outlives the modal: cancelling before activation leaves the pen // physically down with no modal to blame. - state.cancel_ocr(); + state.cancel_region_ui_only(); assert!(!drop_stylus_pressure(true, false, &state)); assert!(drop_stylus_pressure(true, true, &state)); diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 09af58a4b..569d12148 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -10,7 +10,7 @@ use crate::backend::wayland::state::{ }; use crate::backend::wayland::toolbar_intent::intent_to_event; use crate::input::MouseButton; -use crate::input::state::{HelpOverlayPressSource, OcrInputSource}; +use crate::input::state::{HelpOverlayPressSource, RegionInputSource}; use crate::ui::ZoomChipPress; impl TouchHandler for WaylandState { @@ -118,7 +118,7 @@ impl WaylandState { // started has to end here — otherwise the selector, and any freeze it // owns, would outlive the touch that opened it. A region another device // is dragging is untouched. - self.cancel_ocr_selection_from(OcrInputSource::Touch); + self.cancel_region_selection_from(RegionInputSource::Touch); self.set_pending_toast_press(None); self.set_pending_status_hud_press(false); self.set_pending_zoom_chip_press(ZoomChipPress::None); @@ -209,7 +209,7 @@ impl WaylandState { .clear_help_overlay_press_for(HelpOverlayPressSource::Touch); } - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); let inline_hit = target == TouchTarget::Overlay && inline_active @@ -217,8 +217,8 @@ impl WaylandState { if target == TouchTarget::Toolbar || inline_hit { self.cancel_ocr_for_toolbar_interaction(); } else if target == TouchTarget::Overlay { - self.begin_ocr_selection( - OcrInputSource::Touch, + self.begin_region_selection( + RegionInputSource::Touch, screen_position.0, screen_position.1, ); @@ -358,10 +358,10 @@ impl WaylandState { let screen_y = screen_position.1.round() as i32; self.set_current_mouse(screen_x, screen_y); - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { if target == TouchTarget::Overlay { - self.update_ocr_selection( - OcrInputSource::Touch, + self.update_region_selection( + RegionInputSource::Touch, screen_position.0, screen_position.1, ); @@ -448,11 +448,11 @@ impl WaylandState { position: (f64, f64), target: TouchTarget, ) { - if self.input_state.ocr_is_active() { + if self.input_state.region_is_active() { if let Some((x, y)) = self.touch_screen_position(surface, position, target) { - self.finish_ocr_selection(OcrInputSource::Touch, x, y); + self.finish_region_selection(RegionInputSource::Touch, x, y); } else { - self.cancel_ocr_selection_from(OcrInputSource::Touch); + self.cancel_region_selection_from(RegionInputSource::Touch); } return; } diff --git a/src/backend/wayland/mod.rs b/src/backend/wayland/mod.rs index 304fc8c36..e18aa58aa 100644 --- a/src/backend/wayland/mod.rs +++ b/src/backend/wayland/mod.rs @@ -1,3 +1,4 @@ +mod acquisition; mod backend; mod capture; mod clipboard; diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 0b1e2aaaf..2e0cbfce0 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -91,6 +91,7 @@ use super::{ zoom::ZoomState, }; +mod acquisition; mod activation; mod boards; mod buffer_damage; @@ -112,6 +113,7 @@ mod ocr; mod onboarding; mod pdf_export; mod perf; +mod region_capture; mod render; mod screen_image; mod text_clipboard; diff --git a/src/backend/wayland/state/acquisition.rs b/src/backend/wayland/state/acquisition.rs new file mode 100644 index 000000000..1ac2a0323 --- /dev/null +++ b/src/backend/wayland/state/acquisition.rs @@ -0,0 +1,1300 @@ +use crate::backend::wayland::acquisition::{ + AcquisitionRecord, AcquisitionStage, ScreenAcquisitionBusy, ScreenAcquisitionCompletion, + ScreenAcquisitionId, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, + rejected_ready_generation, +}; +use crate::backend::wayland::zoom::{ + ZoomSourceOutcome, ZoomSourceTerminal, ZoomWaiter, ZoomWaiterOwner, +}; +use crate::input::state::{EyedropperCaptureSource, ScreenCaptureSource, Toast, ToastPriority}; + +use super::region_capture::owned_generation_is_current; +use super::screen_image::displayed_screen_image; +use super::{OverlaySuppression, WaylandState}; + +const INCONSISTENT_CAPTURE_MESSAGE: &str = "Screen capture state was inconsistent; try again."; + +fn report_inconsistent_capture_to(input_state: &mut crate::input::InputState) { + input_state.push_toast( + ToastPriority::Critical, + "capture", + Toast::error(INCONSISTENT_CAPTURE_MESSAGE), + ); +} + +fn zoom_terminal_report( + owner: Option, + terminal: &ZoomSourceTerminal, +) -> Option<(&'static str, &str)> { + if let Some(report) = terminal.report.as_ref() { + return Some((report.source, report.message.as_str())); + } + let outcome = &terminal.outcome; + if matches!( + outcome, + ZoomSourceOutcome::Ready { .. } + | ZoomSourceOutcome::Cancelled + | ZoomSourceOutcome::StaleLayout + ) { + return None; + } + match owner? { + ZoomWaiterOwner::Eyedropper => Some(("eyedropper", "Screen eyedropper capture failed.")), + ZoomWaiterOwner::Ocr => Some(("ocr", "Screen capture for text recognition failed.")), + ZoomWaiterOwner::RegionCapture => None, + } +} + +pub(super) fn report_zoom_terminal_to( + input_state: &mut crate::input::InputState, + owner: Option, + terminal: &ZoomSourceTerminal, +) { + let Some((source, message)) = zoom_terminal_report(owner, terminal) else { + return; + }; + input_state.push_toast(ToastPriority::Critical, source, Toast::error(message)); +} + +fn report_screen_terminal_to( + input_state: &mut crate::input::InputState, + owner: ScreenAcquisitionOwner, + outcome: &ScreenAcquisitionOutcome, +) { + if owner == ScreenAcquisitionOwner::UserFreeze + && matches!(outcome, ScreenAcquisitionOutcome::Unavailable) + { + input_state.push_toast( + ToastPriority::Info, + "capture", + Toast::warning("Freeze is already preparing another overlay operation."), + ); + return; + } + let message = match outcome { + ScreenAcquisitionOutcome::Ready { .. } | ScreenAcquisitionOutcome::Cancelled => return, + ScreenAcquisitionOutcome::StaleLayout => "Freeze failed after the display layout changed", + ScreenAcquisitionOutcome::Failed(message) => message, + ScreenAcquisitionOutcome::Unavailable => match owner { + ScreenAcquisitionOwner::UserFreeze => unreachable!("handled above"), + ScreenAcquisitionOwner::Eyedropper => "Screen eyedropper capture failed.", + ScreenAcquisitionOwner::Ocr => "Screen capture for text recognition failed.", + ScreenAcquisitionOwner::RegionCapture => return, + }, + }; + let source = match owner { + ScreenAcquisitionOwner::UserFreeze => "freeze", + ScreenAcquisitionOwner::Eyedropper => "eyedropper", + ScreenAcquisitionOwner::Ocr => "ocr", + ScreenAcquisitionOwner::RegionCapture => "capture", + }; + input_state.push_toast( + ToastPriority::Critical, + source, + Toast::error(message.to_string()), + ); +} + +/// Effect boundary for the acquisition transaction. `WaylandState` supplies +/// the live protocol/UI effects; tests supply observable component effects. +/// The routing and fail-closed decisions below are shared production code. +trait AcquisitionTransactionRuntime { + fn acquisition_slot(&self) -> Option; + fn take_acquisition_record(&mut self) -> Option; + fn take_matching_acquisition_record( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option; + fn owner_waiter_matches( + &self, + record: &AcquisitionRecord, + completion: &ScreenAcquisitionCompletion, + ) -> bool; + fn input_state(&mut self) -> &mut crate::input::InputState; + fn finish_eyedropper_ready(&mut self, installed_generation: u64); + fn finish_ocr_ready(&mut self, installed_generation: u64); + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner); + fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner); + fn frozen_generation(&self) -> u64; + fn frozen_active(&self) -> bool; + fn restore_xdg_after_frozen_effect(&mut self); + fn unfreeze_frozen_effect(&mut self); + fn abandon_frozen_effect(&mut self); + fn frozen_completion(&self) -> Option; + fn take_matching_frozen_completion( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option; + fn take_frozen_capture_done(&mut self) -> bool; + fn frozen_suppressed(&self) -> bool; + fn end_frozen_suppression(&mut self); +} + +fn release_owned_generation( + runtime: &mut R, + generation: u64, +) -> bool { + if !owned_generation_is_current( + generation, + runtime.frozen_generation(), + runtime.frozen_active(), + ) { + return false; + } + runtime.restore_xdg_after_frozen_effect(); + runtime.unfreeze_frozen_effect(); + true +} + +fn abandon_started_acquisition(runtime: &mut R) { + runtime.restore_xdg_after_frozen_effect(); + runtime.abandon_frozen_effect(); +} + +fn cancel_owner_transition( + runtime: &mut R, + owner: ScreenAcquisitionOwner, +) { + match owner { + ScreenAcquisitionOwner::Eyedropper => { + runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Eyedropper); + } + ScreenAcquisitionOwner::Ocr => { + runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Ocr); + } + ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => {} + } + runtime.cancel_owner_ui(owner); +} + +fn fail_closed_acquisition_transaction( + runtime: &mut R, + record: Option, + completion: ScreenAcquisitionCompletion, +) { + if record.is_some_and(|record| { + record.stage == AcquisitionStage::Started + && (record.id != completion.id || record.owner != completion.owner) + }) { + abandon_started_acquisition(runtime); + } + if runtime.frozen_suppressed() { + runtime.end_frozen_suppression(); + } + if let Some(generation) = rejected_ready_generation( + completion.owner, + &completion.outcome, + runtime.frozen_generation(), + runtime.frozen_active(), + ) { + release_owned_generation(runtime, generation); + } + cancel_owner_transition(runtime, completion.owner); + if let Some(record) = record + && record.owner != completion.owner + { + cancel_owner_transition(runtime, record.owner); + } + report_inconsistent_capture_to(runtime.input_state()); +} + +fn route_acquisition_transaction( + runtime: &mut R, + completion: ScreenAcquisitionCompletion, +) { + let registry_matches = runtime + .acquisition_slot() + .is_some_and(|record| record.id == completion.id && record.owner == completion.owner); + let record = runtime.take_acquisition_record(); + if !registry_matches { + fail_closed_acquisition_transaction(runtime, record, completion); + return; + } + let record = record.expect("a matched screen acquisition record exists"); + if !runtime.owner_waiter_matches(&record, &completion) { + fail_closed_acquisition_transaction(runtime, Some(record), completion); + return; + } + + report_screen_terminal_to(runtime.input_state(), completion.owner, &completion.outcome); + match (completion.owner, completion.outcome) { + (ScreenAcquisitionOwner::UserFreeze, _) => {} + ( + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOutcome::Ready { + installed_generation, + }, + ) => runtime.finish_eyedropper_ready(installed_generation), + ( + ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOutcome::Ready { + installed_generation, + }, + ) => runtime.finish_ocr_ready(installed_generation), + (ScreenAcquisitionOwner::Eyedropper | ScreenAcquisitionOwner::Ocr, _) => { + cancel_owner_transition(runtime, completion.owner); + } + (ScreenAcquisitionOwner::RegionCapture, _) => { + // Phase 1 supplies the capture-purpose transition and legacy handoff. + } + } +} + +fn cancel_acquisition_transaction( + runtime: &mut R, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, +) -> bool { + let Some(record) = runtime + .acquisition_slot() + .filter(|record| record.id == id && record.owner == owner) + else { + log::debug!("Ignoring stale {owner:?} screen acquisition cancellation for {id:?}"); + return false; + }; + + if record.stage == AcquisitionStage::Started + && runtime.frozen_completion().is_some() + && !runtime + .frozen_completion() + .is_some_and(|completion| completion.id == id && completion.owner == owner) + { + log::debug!( + "Retaining mismatched frozen completion for fail-closed routing while cancelling {owner:?} {id:?}" + ); + return false; + } + + let taken = runtime + .take_matching_acquisition_record(id, owner) + .expect("matching acquisition record was just validated"); + if taken.stage == AcquisitionStage::Queued { + return true; + } + + if let Some(completion) = runtime.take_matching_frozen_completion(id, owner) { + report_screen_terminal_to(runtime.input_state(), owner, &completion.outcome); + if owner != ScreenAcquisitionOwner::UserFreeze + && let ScreenAcquisitionOutcome::Ready { + installed_generation, + } = completion.outcome + { + release_owned_generation(runtime, installed_generation); + } + } else if runtime.frozen_completion().is_none() { + abandon_started_acquisition(runtime); + } + let _ = runtime.take_frozen_capture_done(); + if runtime.frozen_suppressed() { + runtime.end_frozen_suppression(); + } + true +} + +fn cancel_modal_owner_resources( + runtime: &mut R, + owner: ScreenAcquisitionOwner, + pending_acquisition: Option, + owned_generation: Option, +) { + if let Some(id) = pending_acquisition { + cancel_acquisition_transaction(runtime, id, owner); + } + if let Some(generation) = owned_generation { + release_owned_generation(runtime, generation); + } + cancel_owner_transition(runtime, owner); +} + +impl AcquisitionTransactionRuntime for WaylandState { + fn acquisition_slot(&self) -> Option { + self.data.screen_acquisition.slot().copied() + } + + fn take_acquisition_record(&mut self) -> Option { + self.data.screen_acquisition.take() + } + + fn take_matching_acquisition_record( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + self.data.screen_acquisition.take_matching(id, owner) + } + + fn owner_waiter_matches( + &self, + record: &AcquisitionRecord, + completion: &ScreenAcquisitionCompletion, + ) -> bool { + self.validate_owner_waiter(record, completion) + } + + fn input_state(&mut self) -> &mut crate::input::InputState { + &mut self.input_state + } + + fn finish_eyedropper_ready(&mut self, installed_generation: u64) { + self.finish_pending_eyedropper_capture( + EyedropperCaptureSource::Frozen, + Some(installed_generation), + ); + } + + fn finish_ocr_ready(&mut self, installed_generation: u64) { + self.finish_pending_ocr_capture(ScreenCaptureSource::Frozen, installed_generation); + } + + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { + WaylandState::cancel_owner_ui_only(self, owner); + } + + fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner) { + self.clear_zoom_waiter_for(owner); + } + + fn frozen_generation(&self) -> u64 { + self.frozen.image_generation() + } + + fn frozen_active(&self) -> bool { + self.input_state.frozen_active() + } + + fn restore_xdg_after_frozen_effect(&mut self) { + self.restore_xdg_after_frozen(); + } + + fn unfreeze_frozen_effect(&mut self) { + self.frozen.unfreeze(&mut self.input_state); + } + + fn abandon_frozen_effect(&mut self) { + self.frozen.abandon_acquisition(&mut self.input_state); + } + + fn frozen_completion(&self) -> Option { + self.frozen.acquisition_completion().cloned() + } + + fn take_matching_frozen_completion( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + self.frozen.take_matching_acquisition_completion(id, owner) + } + + fn take_frozen_capture_done(&mut self) -> bool { + self.frozen.take_capture_done() + } + + fn frozen_suppressed(&self) -> bool { + self.data.overlay_suppression == OverlaySuppression::Frozen + } + + fn end_frozen_suppression(&mut self) { + self.exit_overlay_suppression(OverlaySuppression::Frozen); + } +} + +impl WaylandState { + pub(in crate::backend::wayland) fn wait_for_current_zoom_capture( + &mut self, + owner: ZoomWaiterOwner, + ) -> bool { + let Some(id) = self.zoom.current_capture_id() else { + return false; + }; + self.data.zoom_waiter.register(ZoomWaiter { id, owner }) + } + + pub(in crate::backend::wayland) fn clear_zoom_waiter_for( + &mut self, + owner: ZoomWaiterOwner, + ) -> bool { + self.data.zoom_waiter.clear_owner(owner) + } + + pub(in crate::backend::wayland) fn resolve_zoom_waiter( + &mut self, + terminal: ZoomSourceTerminal, + ) { + let Some((waiter, matches)) = self.data.zoom_waiter.take_for_terminal(&terminal) else { + report_zoom_terminal_to(&mut self.input_state, None, &terminal); + return; + }; + if !matches { + self.cancel_zoom_owner_ui_only(waiter.owner); + report_inconsistent_capture_to(&mut self.input_state); + return; + } + if !self.validate_zoom_waiter(waiter, &terminal) { + self.cancel_zoom_owner_ui_only(waiter.owner); + report_inconsistent_capture_to(&mut self.input_state); + return; + } + + self.report_zoom_terminal(waiter.owner, &terminal); + match terminal.outcome { + ZoomSourceOutcome::Ready { + installed_generation, + } => match waiter.owner { + ZoomWaiterOwner::Eyedropper => { + self.finish_pending_eyedropper_capture(EyedropperCaptureSource::Zoom, None) + } + ZoomWaiterOwner::Ocr => { + self.finish_pending_ocr_capture(ScreenCaptureSource::Zoom, installed_generation) + } + ZoomWaiterOwner::RegionCapture => {} + }, + _ => self.cancel_zoom_owner_ui_only(waiter.owner), + } + } + + pub(in crate::backend::wayland) fn resolve_pending_zoom_terminal(&mut self) { + if let Some(terminal) = self.zoom.take_source_terminal() { + self.resolve_zoom_waiter(terminal); + } + } + + fn validate_zoom_waiter(&self, waiter: ZoomWaiter, terminal: &ZoomSourceTerminal) -> bool { + let waiting = match waiter.owner { + ZoomWaiterOwner::Eyedropper => { + self.input_state.eyedropper_state().pending_source() + == Some(EyedropperCaptureSource::Zoom) + } + ZoomWaiterOwner::Ocr => self.data.active_screen_region.is_some_and(|region| { + region.purpose() == crate::input::state::RegionPurposeTag::Ocr + && matches!( + region, + super::region_capture::ActiveScreenRegion::PendingZoom { .. } + ) + }), + ZoomWaiterOwner::RegionCapture => false, + }; + if !waiting { + return false; + } + match terminal.outcome { + ZoomSourceOutcome::Ready { + installed_generation, + } => { + self.zoom.image_generation() == installed_generation + && displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) + .is_some() + } + _ => true, + } + } + + fn report_zoom_terminal(&mut self, owner: ZoomWaiterOwner, terminal: &ZoomSourceTerminal) { + report_zoom_terminal_to(&mut self.input_state, Some(owner), terminal); + } + + fn cancel_zoom_owner_ui_only(&mut self, owner: ZoomWaiterOwner) { + match owner { + ZoomWaiterOwner::Eyedropper => self.cancel_eyedropper_ui_only(), + ZoomWaiterOwner::Ocr => self.cancel_ocr_ui_only(), + ZoomWaiterOwner::RegionCapture => {} + } + } + + pub(in crate::backend::wayland) fn request_screen_acquisition( + &mut self, + owner: ScreenAcquisitionOwner, + ) -> Result { + self.data.screen_acquisition.request(owner) + } + + pub(in crate::backend::wayland) fn queued_screen_acquisition( + &self, + ) -> Option { + self.data + .screen_acquisition + .slot() + .copied() + .filter(|record| record.stage == AcquisitionStage::Queued) + } + + pub(in crate::backend::wayland) fn screen_acquisition_slot(&self) -> Option { + self.data.screen_acquisition.slot().copied() + } + + pub(in crate::backend::wayland) fn mark_screen_acquisition_started( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> bool { + self.data.screen_acquisition.mark_started(id, owner) + } + + pub(in crate::backend::wayland) fn complete_queued_acquisition( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + outcome: ScreenAcquisitionOutcome, + ) { + debug_assert!(!matches!(outcome, ScreenAcquisitionOutcome::Ready { .. })); + self.route_acquisition_completion(ScreenAcquisitionCompletion { id, owner, outcome }); + } + + pub(in crate::backend::wayland) fn route_acquisition_completion( + &mut self, + completion: ScreenAcquisitionCompletion, + ) { + route_acquisition_transaction(self, completion); + } + + fn validate_owner_waiter( + &self, + record: &AcquisitionRecord, + completion: &ScreenAcquisitionCompletion, + ) -> bool { + let waiting = match completion.owner { + ScreenAcquisitionOwner::UserFreeze => true, + ScreenAcquisitionOwner::Eyedropper => { + debug_assert_eq!(record.owner, ScreenAcquisitionOwner::Eyedropper); + self.input_state.eyedropper_state().pending_source() + == Some(EyedropperCaptureSource::Frozen) + } + ScreenAcquisitionOwner::Ocr => self.data.active_screen_region.is_some_and(|region| { + region.purpose() == crate::input::state::RegionPurposeTag::Ocr + && region.waits_for_acquisition(completion.id) + }), + ScreenAcquisitionOwner::RegionCapture => false, + }; + if !waiting { + return false; + } + let ScreenAcquisitionOutcome::Ready { + installed_generation, + } = completion.outcome + else { + return true; + }; + match completion.owner { + ScreenAcquisitionOwner::UserFreeze => true, + ScreenAcquisitionOwner::Eyedropper => { + self.frozen.image_generation() == installed_generation + && displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) + .is_some() + } + ScreenAcquisitionOwner::Ocr => { + self.frozen.image_generation() == installed_generation + && displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) + .is_some() + } + ScreenAcquisitionOwner::RegionCapture => false, + } + } + + pub(in crate::backend::wayland) fn report_terminal( + &mut self, + owner: ScreenAcquisitionOwner, + outcome: &ScreenAcquisitionOutcome, + ) { + report_screen_terminal_to(&mut self.input_state, owner, outcome); + } + + pub(in crate::backend::wayland) fn cancel_eyedropper_ui_only(&mut self) { + let _ = self.input_state.cancel_eyedropper(); + } + + pub(in crate::backend::wayland) fn cancel_ocr_ui_only(&mut self) { + self.clear_screen_region_ui_only(); + } + + fn cancel_owner_ui_only(&mut self, owner: ScreenAcquisitionOwner) { + match owner { + ScreenAcquisitionOwner::UserFreeze => {} + ScreenAcquisitionOwner::Eyedropper => self.cancel_eyedropper_ui_only(), + ScreenAcquisitionOwner::Ocr => self.cancel_ocr_ui_only(), + ScreenAcquisitionOwner::RegionCapture => { + // Phase 1 supplies the region UI-only teardown. + } + } + } + + pub(super) fn cancel_modal_owner_resources( + &mut self, + owner: ScreenAcquisitionOwner, + pending_acquisition: Option, + owned_generation: Option, + ) { + cancel_modal_owner_resources(self, owner, pending_acquisition, owned_generation); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; + use crate::input::state::test_support::make_test_input_state; + + struct TransactionRuntime { + registry: ScreenAcquisitionRegistry, + waiter_matches: bool, + input_state: crate::input::InputState, + finished_eyedropper: Vec, + finished_ocr: Vec, + cancelled_owners: Vec, + cleared_zoom_waiters: Vec, + frozen_generation: u64, + frozen_active: bool, + restore_count: usize, + unfreeze_count: usize, + abandon_count: usize, + frozen_completion: Option, + capture_done: bool, + frozen_suppressed: bool, + suppression_end_count: usize, + } + + impl TransactionRuntime { + fn started(owner: ScreenAcquisitionOwner) -> Self { + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry.request(owner).expect("transaction slot"); + assert!(registry.mark_started(id, owner)); + Self { + registry, + waiter_matches: true, + input_state: make_test_input_state(), + finished_eyedropper: Vec::new(), + finished_ocr: Vec::new(), + cancelled_owners: Vec::new(), + cleared_zoom_waiters: Vec::new(), + frozen_generation: 7, + frozen_active: true, + restore_count: 0, + unfreeze_count: 0, + abandon_count: 0, + frozen_completion: None, + capture_done: false, + frozen_suppressed: true, + suppression_end_count: 0, + } + } + + fn record(&self) -> Option { + self.registry.slot().copied() + } + } + + impl AcquisitionTransactionRuntime for TransactionRuntime { + fn acquisition_slot(&self) -> Option { + self.record() + } + + fn take_acquisition_record(&mut self) -> Option { + self.registry.take() + } + + fn take_matching_acquisition_record( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + self.registry.take_matching(id, owner) + } + + fn owner_waiter_matches( + &self, + _record: &AcquisitionRecord, + _completion: &ScreenAcquisitionCompletion, + ) -> bool { + self.waiter_matches + } + + fn input_state(&mut self) -> &mut crate::input::InputState { + &mut self.input_state + } + + fn finish_eyedropper_ready(&mut self, installed_generation: u64) { + self.finished_eyedropper.push(installed_generation); + } + + fn finish_ocr_ready(&mut self, installed_generation: u64) { + self.finished_ocr.push(installed_generation); + } + + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { + self.cancelled_owners.push(owner); + } + + fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner) { + self.cleared_zoom_waiters.push(owner); + } + + fn frozen_generation(&self) -> u64 { + self.frozen_generation + } + + fn frozen_active(&self) -> bool { + self.frozen_active + } + + fn restore_xdg_after_frozen_effect(&mut self) { + self.restore_count += 1; + } + + fn unfreeze_frozen_effect(&mut self) { + self.unfreeze_count += 1; + self.frozen_active = false; + } + + fn abandon_frozen_effect(&mut self) { + self.abandon_count += 1; + self.frozen_active = false; + } + + fn frozen_completion(&self) -> Option { + self.frozen_completion.clone() + } + + fn take_matching_frozen_completion( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + if !self + .frozen_completion + .as_ref() + .is_some_and(|completion| completion.id == id && completion.owner == owner) + { + return None; + } + self.frozen_completion.take() + } + + fn take_frozen_capture_done(&mut self) -> bool { + std::mem::take(&mut self.capture_done) + } + + fn frozen_suppressed(&self) -> bool { + self.frozen_suppressed + } + + fn end_frozen_suppression(&mut self) { + self.frozen_suppressed = false; + self.suppression_end_count += 1; + } + } + + #[test] + fn zoom_owner_outcome_reporting_matrix_is_typed() { + let outcomes = [ + ( + ZoomSourceOutcome::Ready { + installed_generation: 1, + }, + false, + ), + (ZoomSourceOutcome::Cancelled, false), + (ZoomSourceOutcome::StaleLayout, false), + (ZoomSourceOutcome::Aborted, true), + (ZoomSourceOutcome::Deactivated, true), + (ZoomSourceOutcome::Failed("detail".to_string()), true), + ]; + + for owner in [ZoomWaiterOwner::Eyedropper, ZoomWaiterOwner::Ocr] { + for (outcome, reports) in &outcomes { + assert_eq!( + zoom_terminal_report( + Some(owner), + &ZoomSourceTerminal::for_test(outcome.clone(), None), + ) + .is_some(), + *reports, + "owner={owner:?} outcome={outcome:?}" + ); + } + } + for (outcome, _) in &outcomes { + assert_eq!( + zoom_terminal_report( + Some(ZoomWaiterOwner::RegionCapture), + &ZoomSourceTerminal::for_test(outcome.clone(), None), + ), + None + ); + } + } + + #[test] + fn specific_zoom_failure_report_replaces_the_owner_fallback() { + let terminal = ZoomSourceTerminal::for_test( + ZoomSourceOutcome::Failed("specific backend failure".to_string()), + Some(crate::backend::wayland::zoom::ZoomTerminalReport { + source: "zoom", + message: "specific backend failure".to_string(), + }), + ); + + for owner in [Some(ZoomWaiterOwner::Ocr), None] { + let mut input_state = make_test_input_state(); + + report_zoom_terminal_to(&mut input_state, owner, &terminal); + + assert_eq!(input_state.test_toast_count(), 1); + assert_eq!( + input_state.test_active_toast_message(), + Some("specific backend failure") + ); + } + } + + #[test] + fn zoom_correlation_failure_emits_exactly_one_inconsistency_toast() { + let mut input_state = make_test_input_state(); + + report_inconsistent_capture_to(&mut input_state); + + assert_eq!(input_state.test_toast_count(), 1); + assert_eq!( + input_state.test_active_toast_message(), + Some(INCONSISTENT_CAPTURE_MESSAGE) + ); + } + + #[test] + fn screen_terminal_reporting_is_exactly_once_for_the_full_owner_outcome_matrix() { + for owner in [ + ScreenAcquisitionOwner::UserFreeze, + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOwner::RegionCapture, + ] { + let key = match owner { + ScreenAcquisitionOwner::UserFreeze => "freeze", + ScreenAcquisitionOwner::Eyedropper => "eyedropper", + ScreenAcquisitionOwner::Ocr => "ocr", + ScreenAcquisitionOwner::RegionCapture => "capture", + }; + let unavailable = match owner { + ScreenAcquisitionOwner::UserFreeze => Some(( + "capture", + "Freeze is already preparing another overlay operation.", + )), + ScreenAcquisitionOwner::Eyedropper => { + Some((key, "Screen eyedropper capture failed.")) + } + ScreenAcquisitionOwner::Ocr => { + Some((key, "Screen capture for text recognition failed.")) + } + ScreenAcquisitionOwner::RegionCapture => None, + }; + let cases = [ + ( + ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + None, + ), + (ScreenAcquisitionOutcome::Cancelled, None), + (ScreenAcquisitionOutcome::Unavailable, unavailable), + ( + ScreenAcquisitionOutcome::StaleLayout, + Some((key, "Freeze failed after the display layout changed")), + ), + ( + ScreenAcquisitionOutcome::Failed("specific backend failure".to_string()), + Some((key, "specific backend failure")), + ), + ]; + + for (outcome, expected) in cases { + let mut input_state = make_test_input_state(); + + report_screen_terminal_to(&mut input_state, owner, &outcome); + + assert_eq!( + input_state.test_toast_count(), + usize::from(expected.is_some()), + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + input_state.test_active_toast_message(), + expected.map(|(_, message)| message), + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + input_state.test_active_toast_key(), + expected.map(|(key, _)| key), + "owner={owner:?} outcome={outcome:?}" + ); + } + } + } + + #[test] + fn matched_ready_completions_run_the_owner_production_transactions() { + for owner in [ + ScreenAcquisitionOwner::UserFreeze, + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ] { + let mut runtime = TransactionRuntime::started(owner); + if owner == ScreenAcquisitionOwner::Ocr { + runtime.frozen_generation = 99; + } + let id = runtime.record().expect("started record").id; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }, + ); + + assert_eq!(runtime.record(), None, "owner={owner:?}"); + assert_eq!( + runtime.finished_eyedropper, + if owner == ScreenAcquisitionOwner::Eyedropper { + vec![7] + } else { + Vec::new() + }, + "owner={owner:?}" + ); + assert_eq!( + runtime.finished_ocr, + if owner == ScreenAcquisitionOwner::Ocr { + vec![7] + } else { + Vec::new() + }, + "owner={owner:?}" + ); + assert_eq!(runtime.input_state.test_toast_count(), 0); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + } + } + + #[test] + fn matched_nonready_completions_report_then_cancel_the_owner_ui() { + for owner in [ + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ] { + for (outcome, expected_toasts) in [ + (ScreenAcquisitionOutcome::Cancelled, 0), + (ScreenAcquisitionOutcome::Unavailable, 1), + (ScreenAcquisitionOutcome::StaleLayout, 1), + ( + ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), + 1, + ), + ] { + let mut runtime = TransactionRuntime::started(owner); + let id = runtime.record().expect("started record").id; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: outcome.clone(), + }, + ); + + assert_eq!( + runtime.record(), + None, + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + runtime.cancelled_owners, + vec![owner], + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + runtime.input_state.test_toast_count(), + expected_toasts, + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + } + } + } + + #[test] + fn id_owner_waiter_and_empty_slot_mismatches_fail_closed_once() { + let cases = ["id", "owner", "waiter", "empty"]; + for case in cases { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let record = runtime.record().expect("started record"); + let (id, owner) = match case { + "id" => { + let mut other = ScreenAcquisitionRegistry::default(); + other + .request(ScreenAcquisitionOwner::Ocr) + .expect("first other id"); + other.take(); + let different = other + .request(ScreenAcquisitionOwner::Ocr) + .expect("different id"); + (different, ScreenAcquisitionOwner::Ocr) + } + "owner" => (record.id, ScreenAcquisitionOwner::Eyedropper), + "waiter" => { + runtime.waiter_matches = false; + (record.id, record.owner) + } + "empty" => { + runtime.registry.take(); + (record.id, record.owner) + } + _ => unreachable!(), + }; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Failed("failure".to_string()), + }, + ); + + assert_eq!(runtime.record(), None, "case={case}"); + assert_eq!(runtime.input_state.test_toast_count(), 1, "case={case}"); + assert_eq!( + runtime.input_state.test_active_toast_message(), + Some(INCONSISTENT_CAPTURE_MESSAGE), + "case={case}" + ); + assert!(!runtime.frozen_suppressed, "case={case}"); + assert_eq!(runtime.suppression_end_count, 1, "case={case}"); + assert_eq!( + runtime.abandon_count, + usize::from(matches!(case, "id" | "owner")), + "case={case}" + ); + } + } + + #[test] + fn activation_terminal_then_same_batch_cancel_releases_once_and_cleans_up() { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let id = runtime.record().expect("started record").id; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Ocr, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }); + runtime.capture_done = true; + + assert!(cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + + assert_eq!(runtime.record(), None); + assert_eq!(runtime.frozen_completion, None); + assert!(!runtime.capture_done); + assert!(!runtime.frozen_active); + assert_eq!(runtime.restore_count, 1); + assert_eq!(runtime.unfreeze_count, 1); + assert!(!runtime.frozen_suppressed); + assert_eq!(runtime.suppression_end_count, 1); + assert_eq!(runtime.input_state.test_toast_count(), 0); + + assert!(!cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + assert_eq!(runtime.restore_count, 1); + assert_eq!(runtime.unfreeze_count, 1); + assert_eq!(runtime.suppression_end_count, 1); + } + + #[test] + fn started_and_queued_cancellation_have_exact_resource_postconditions() { + let mut started = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); + let started_id = started.record().expect("started record").id; + started.capture_done = true; + + assert!(cancel_acquisition_transaction( + &mut started, + started_id, + ScreenAcquisitionOwner::Eyedropper, + )); + assert_eq!(started.record(), None); + assert_eq!(started.restore_count, 1); + assert_eq!(started.abandon_count, 1); + assert_eq!(started.unfreeze_count, 0); + assert!(!started.capture_done); + assert!(!started.frozen_suppressed); + assert_eq!(started.suppression_end_count, 1); + assert_eq!(started.input_state.test_toast_count(), 0); + + let mut queued = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let queued_record = queued.registry.take().expect("started record"); + let queued_id = queued + .registry + .request(ScreenAcquisitionOwner::Ocr) + .expect("queued replacement"); + assert_ne!(queued_record.id, queued_id); + queued.frozen_suppressed = false; + + assert!(cancel_acquisition_transaction( + &mut queued, + queued_id, + ScreenAcquisitionOwner::Ocr, + )); + assert_eq!(queued.record(), None); + assert_eq!(queued.restore_count, 0); + assert_eq!(queued.abandon_count, 0); + assert_eq!(queued.unfreeze_count, 0); + assert_eq!(queued.suppression_end_count, 0); + } + + #[test] + fn cancellation_reports_matching_nonready_terminals_without_releasing_pixels() { + for outcome in [ + ScreenAcquisitionOutcome::Cancelled, + ScreenAcquisitionOutcome::Unavailable, + ScreenAcquisitionOutcome::StaleLayout, + ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), + ] { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let id = runtime.record().expect("started record").id; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Ocr, + outcome: outcome.clone(), + }); + + assert!(cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + + assert_eq!(runtime.frozen_completion, None, "outcome={outcome:?}"); + assert_eq!(runtime.restore_count, 0, "outcome={outcome:?}"); + assert_eq!(runtime.unfreeze_count, 0, "outcome={outcome:?}"); + assert_eq!( + runtime.input_state.test_toast_count(), + usize::from(!matches!(outcome, ScreenAcquisitionOutcome::Cancelled)), + "outcome={outcome:?}" + ); + assert!(!runtime.frozen_suppressed, "outcome={outcome:?}"); + } + } + + #[test] + fn mismatched_terminal_is_retained_for_fail_closed_routing() { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let id = runtime.record().expect("started record").id; + let mut other = ScreenAcquisitionRegistry::default(); + other + .request(ScreenAcquisitionOwner::Ocr) + .expect("other first id"); + other.take(); + let other_id = other + .request(ScreenAcquisitionOwner::Ocr) + .expect("different completion id"); + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id: other_id, + owner: ScreenAcquisitionOwner::Ocr, + outcome: ScreenAcquisitionOutcome::Failed("other".to_string()), + }); + + assert!(!cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + + assert!(runtime.record().is_some()); + assert!(runtime.frozen_completion.is_some()); + assert!(runtime.frozen_suppressed); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.abandon_count, 0); + assert_eq!(runtime.input_state.test_toast_count(), 0); + } + + #[test] + fn replacement_generation_survives_ready_cancel_and_stale_release() { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); + let id = runtime.record().expect("started record").id; + runtime.frozen_generation = 8; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Eyedropper, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }); + + assert!(cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Eyedropper, + )); + assert!(runtime.frozen_active); + assert_eq!(runtime.frozen_generation, 8); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + + assert!(!release_owned_generation(&mut runtime, 7)); + assert!(runtime.frozen_active); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + } + + #[test] + fn armed_owner_cleanup_releases_generation_clears_waiter_and_cancels_ui_once() { + for (owner, zoom_owner) in [ + ( + ScreenAcquisitionOwner::Eyedropper, + ZoomWaiterOwner::Eyedropper, + ), + (ScreenAcquisitionOwner::Ocr, ZoomWaiterOwner::Ocr), + ] { + let mut runtime = TransactionRuntime::started(owner); + runtime.registry.take(); + + cancel_modal_owner_resources(&mut runtime, owner, None, Some(7)); + + assert!(!runtime.frozen_active, "owner={owner:?}"); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert_eq!(runtime.cleared_zoom_waiters, vec![zoom_owner]); + assert_eq!(runtime.cancelled_owners, vec![owner]); + + cancel_modal_owner_resources(&mut runtime, owner, None, None); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert_eq!(runtime.cleared_zoom_waiters, vec![zoom_owner, zoom_owner]); + assert_eq!(runtime.cancelled_owners, vec![owner, owner]); + } + } +} diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index b76fc25b7..80809760b 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -41,10 +41,9 @@ impl WaylandState { pub(in crate::backend::wayland) fn apply_capture_completion(&mut self) { if self.frozen.take_capture_done() { self.exit_overlay_suppression(OverlaySuppression::Frozen); - self.finish_pending_eyedropper_capture( - crate::input::state::EyedropperCaptureSource::Frozen, - ); - self.finish_pending_ocr_capture(crate::input::state::OcrCaptureSource::Frozen); + } + if let Some(completion) = self.frozen.take_acquisition_completion() { + self.route_acquisition_completion(completion); } if self.zoom.take_capture_done() { log::info!( @@ -53,11 +52,8 @@ impl WaylandState { self.zoom.image_generation() ); self.exit_overlay_suppression(OverlaySuppression::Zoom); - self.finish_pending_eyedropper_capture( - crate::input::state::EyedropperCaptureSource::Zoom, - ); - self.finish_pending_ocr_capture(crate::input::state::OcrCaptureSource::Zoom); } + self.resolve_pending_zoom_terminal(); } /// Restore the overlay after screenshot capture completes. @@ -177,7 +173,7 @@ impl WaylandState { "Capture action {:?} requested while overlay is suppressed; ignoring", action ); - self.capture.clear_exit_on_success(); + self.capture.finish_capture_lifecycle(); self.input_state.push_toast( ToastPriority::Info, "capture", @@ -348,6 +344,9 @@ impl WaylandState { "{} submission rejected while capture operation {active_id} is active", operation.saved_log_label() ); + self.capture.clear_pending_pdf_export(); + self.show_overlay(); + self.capture.finish_capture_lifecycle(); self.input_state.push_toast( ToastPriority::Info, "capture", @@ -364,11 +363,9 @@ impl WaylandState { fn restore_rejected_capture_submission(&mut self, operation: ImageOperationKind, error: &str) { log::error!("Failed to submit {}: {error}", operation.saved_log_label()); - self.capture.clear_preflight(); self.capture.clear_pending_pdf_export(); self.show_overlay(); - self.capture.clear_in_progress(); - self.capture.clear_exit_on_success(); + self.capture.finish_capture_lifecycle(); self.input_state.push_toast( ToastPriority::Critical, "capture", diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index dd9cd133b..6dd87d00c 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -4,6 +4,26 @@ use std::time::{Duration, Instant}; const MAIN_SURFACE_FRAME_TIMEOUT: Duration = Duration::from_secs(1); +fn push_capture_preflight_cancellation_toast( + input_state: &mut crate::input::InputState, + message: &str, +) { + input_state.push_toast(ToastPriority::Critical, "capture", Toast::warning(message)); +} + +/// Finishes an identified zoom capture with a report-bearing terminal. The +/// event-loop router owns that report; an uncorrelated standalone failure has +/// no terminal, so its caller must retain the immediate capture toast. +fn finish_zoom_preflight_cancellation( + zoom: &mut crate::backend::wayland::zoom::ZoomState, + input_state: &mut crate::input::InputState, + message: &str, +) -> bool { + let terminal_will_report = zoom.current_capture_id().is_some(); + zoom.finish_preflight_failure(input_state, message.to_string()); + terminal_will_report +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MainSurfaceCapturePhase { AwaitingRender, @@ -226,8 +246,10 @@ impl WaylandState { // before restoring suppression state so the recovery frame is allowed // to commit even if the original callback never arrives. self.surface.clear_frame_callback_pending(); - self.cancel_overlay_capture_preflight(timeout.reason); - self.input_state.push_toast(ToastPriority::Critical, "capture", Toast::warning("Screen capture cancelled because the compositor did not confirm the hidden overlay frame.")); + let message = "Screen capture cancelled because the compositor did not confirm the hidden overlay frame."; + if !self.cancel_overlay_capture_preflight(timeout.reason, Some(message)) { + push_capture_preflight_cancellation_toast(&mut self.input_state, message); + } } /// Records the frame callback for the fresh hidden main-surface commit. @@ -281,14 +303,11 @@ impl WaylandState { log::warn!( "capture.preflight id={generation} component=gtk reason={reason:?} phase=capture-cancelled error={error}" ); - self.cancel_overlay_capture_preflight(reason); - self.input_state.push_toast( - ToastPriority::Critical, - "capture", - Toast::warning( - "Screen capture cancelled because GTK toolbar transparency was not confirmed.", - ), - ); + let message = + "Screen capture cancelled because GTK toolbar transparency was not confirmed."; + if !self.cancel_overlay_capture_preflight(reason, Some(message)) { + push_capture_preflight_cancellation_toast(&mut self.input_state, message); + } } /// A failed GTK connection cannot prove that its mapped surfaces painted @@ -300,8 +319,11 @@ impl WaylandState { log::warn!( "Cancelling {reason:?} because the GTK toolbars could not confirm capture suppression" ); - self.cancel_overlay_capture_preflight(reason); - self.input_state.push_toast(ToastPriority::Critical, "capture", Toast::warning("Screen capture cancelled because the GTK toolbar could not become transparent safely.")); + let message = + "Screen capture cancelled because the GTK toolbar could not become transparent safely."; + if !self.cancel_overlay_capture_preflight(reason, Some(message)) { + push_capture_preflight_cancellation_toast(&mut self.input_state, message); + } } fn begin_ready_overlay_capture(&mut self, qh: &QueueHandle) { @@ -313,7 +335,7 @@ impl WaylandState { "Capture barrier completed for {reason:?} while suppression is {:?}; cancelling", self.data.overlay_suppression ); - self.cancel_overlay_capture_preflight(reason); + self.cancel_overlay_capture_preflight(reason, None); return; } @@ -321,7 +343,7 @@ impl WaylandState { OverlaySuppression::Frozen => { 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); + self.cancel_overlay_capture_preflight(reason, None); return; }; if let Err(err) = @@ -329,18 +351,23 @@ impl WaylandState { .begin_preflight_capture(backend, &self.shm, qh, &self.tokio_handle) { log::warn!("Frozen preflight capture failed: {err}"); - self.input_state.push_toast( - ToastPriority::Critical, - "freeze", - Toast::error(err.to_string()), - ); - self.frozen.cancel(&mut self.input_state); + if self.frozen.has_acquisition_attempt() { + self.frozen + .finish_preflight_failure(err.to_string(), &mut self.input_state); + } else { + self.input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error(err.to_string()), + ); + self.frozen.cancel(&mut self.input_state); + } } } OverlaySuppression::Zoom => { let Some(use_fallback) = self.zoom.take_preflight_pending() else { log::warn!("Zoom capture barrier completed without a pending preflight"); - self.cancel_overlay_capture_preflight(reason); + self.cancel_overlay_capture_preflight(reason, None); return; }; if let Err(err) = self.zoom.begin_preflight_capture( @@ -350,25 +377,21 @@ impl WaylandState { &self.tokio_handle, ) { log::warn!("Zoom preflight capture failed: {err}"); - self.input_state.push_toast( - ToastPriority::Critical, - "zoom", - Toast::error(err.to_string()), - ); - self.zoom.cancel(&mut self.input_state, false); + self.zoom + .finish_preflight_failure(&mut self.input_state, err.to_string()); } } OverlaySuppression::Capture | OverlaySuppression::DesktopBackdrop => { let Some(request) = self.capture.take_preflight_request() else { log::warn!("Capture barrier completed without a pending request"); - self.cancel_overlay_capture_preflight(reason); + self.cancel_overlay_capture_preflight(reason, None); return; }; if !self.capture_suppressed() { log::warn!( "Capture preflight completed without capture suppression; cancelling" ); - self.cancel_overlay_capture_preflight(reason); + self.cancel_overlay_capture_preflight(reason, None); } else { self.begin_pending_capture(request); } @@ -379,25 +402,47 @@ impl WaylandState { } } - fn cancel_overlay_capture_preflight(&mut self, reason: OverlaySuppression) { + fn cancel_overlay_capture_preflight( + &mut self, + reason: OverlaySuppression, + frozen_message: Option<&str>, + ) -> bool { match reason { OverlaySuppression::Capture | OverlaySuppression::DesktopBackdrop => { - self.capture.clear_preflight(); - self.capture.clear_in_progress(); - self.capture.clear_exit_on_success(); self.capture.clear_pending_pdf_export(); + self.capture.finish_capture_lifecycle(); self.show_overlay(); + false } OverlaySuppression::Frozen => { - self.frozen.cancel(&mut self.input_state); - self.exit_overlay_suppression(reason); + let acquisition_terminal = self.frozen.has_acquisition_attempt(); + if acquisition_terminal { + self.frozen.finish_acquisition( + crate::backend::wayland::acquisition::ScreenAcquisitionOutcome::Failed( + frozen_message + .unwrap_or("Freeze capture could not start.") + .to_string(), + ), + &mut self.input_state, + ); + } else { + self.frozen.cancel(&mut self.input_state); + self.exit_overlay_suppression(reason); + } + acquisition_terminal } OverlaySuppression::Zoom => { - self.zoom.cancel(&mut self.input_state, false); + let terminal_will_report = finish_zoom_preflight_cancellation( + &mut self.zoom, + &mut self.input_state, + frozen_message.unwrap_or("Zoom capture preflight was cancelled"), + ); self.exit_overlay_suppression(reason); + terminal_will_report } OverlaySuppression::None | OverlaySuppression::ExternalDialog => { self.data.overlay_capture_barrier.cancel(reason); + false } } } @@ -406,6 +451,9 @@ impl WaylandState { #[cfg(test)] mod tests { use super::*; + use crate::backend::wayland::state::acquisition::report_zoom_terminal_to; + use crate::backend::wayland::zoom::ZoomWaiterOwner; + use crate::input::state::test_support::make_test_input_state; #[test] fn main_surface_frame_deadline_starts_only_after_submission() { @@ -590,4 +638,51 @@ mod tests { Some(OverlaySuppression::Zoom) ); } + + #[tokio::test] + async fn zoom_preflight_failure_has_exactly_one_report_with_or_without_a_waiter() { + for owner in [Some(ZoomWaiterOwner::Ocr), None] { + let mut zoom = crate::backend::wayland::zoom::ZoomState::new(None); + let mut input_state = make_test_input_state(); + zoom.start_capture(false, &tokio::runtime::Handle::current()) + .expect("identified zoom capture"); + + assert!(finish_zoom_preflight_cancellation( + &mut zoom, + &mut input_state, + "specific preflight failure", + )); + assert_eq!(input_state.test_toast_count(), 0); + let terminal = zoom.take_source_terminal().expect("typed terminal"); + + report_zoom_terminal_to(&mut input_state, owner, &terminal); + + assert_eq!(input_state.test_toast_count(), 1, "owner={owner:?}"); + assert_eq!( + input_state.test_active_toast_message(), + Some("specific preflight failure"), + "owner={owner:?}" + ); + } + } + + #[test] + fn standalone_zoom_preflight_failure_keeps_the_caller_report() { + let mut zoom = crate::backend::wayland::zoom::ZoomState::new(None); + let mut input_state = make_test_input_state(); + + assert!(!finish_zoom_preflight_cancellation( + &mut zoom, + &mut input_state, + "standalone preflight failure", + )); + push_capture_preflight_cancellation_toast(&mut input_state, "standalone preflight failure"); + + assert_eq!(zoom.take_source_terminal(), None); + assert_eq!(input_state.test_toast_count(), 1); + assert_eq!( + input_state.test_active_toast_message(), + Some("standalone preflight failure") + ); + } } diff --git a/src/backend/wayland/state/core/accessors.rs b/src/backend/wayland/state/core/accessors.rs index ff82d7f03..44850658b 100644 --- a/src/backend/wayland/state/core/accessors.rs +++ b/src/backend/wayland/state/core/accessors.rs @@ -1,4 +1,3 @@ -use crate::input::state::{Toast, ToastPriority}; use smithay_client_toolkit::shell::{WaylandSurface, wlr_layer::Layer}; use super::super::*; @@ -22,6 +21,14 @@ fn xdg_frozen_fullscreen_timeout( ) } +fn finish_xdg_frozen_fullscreen_request( + state: &mut XdgFrozenFullscreenState, + requested_at: &mut Option, +) { + *state = XdgFrozenFullscreenState::Inactive; + *requested_at = None; +} + impl WaylandState { pub(in crate::backend::wayland) fn current_mouse(&self) -> (i32, i32) { (self.data.current_mouse_x, self.data.current_mouse_y) @@ -347,9 +354,10 @@ impl WaylandState { } window.commit(); } - self.data.xdg_frozen_fullscreen_state = - crate::backend::wayland::state::XdgFrozenFullscreenState::Inactive; - self.data.xdg_frozen_fullscreen_requested_at = None; + finish_xdg_frozen_fullscreen_request( + &mut self.data.xdg_frozen_fullscreen_state, + &mut self.data.xdg_frozen_fullscreen_requested_at, + ); } pub(in crate::backend::wayland) fn activate_pending_frozen_image_for_current_surface( @@ -374,8 +382,6 @@ impl WaylandState { Ok(false) => {} Err(err) => { log::warn!("Frozen pending image activation failed: {}", err); - self.input_state - .push_toast(ToastPriority::Critical, "freeze", Toast::error(err)); self.restore_xdg_after_frozen(); } } @@ -447,4 +453,24 @@ mod tests { Some(Duration::ZERO) ); } + + #[test] + fn finishing_xdg_frozen_fullscreen_request_eliminates_an_expired_timeout() { + let start = Instant::now(); + let mut state = XdgFrozenFullscreenState::PendingConfigure; + let mut requested_at = Some(start); + + finish_xdg_frozen_fullscreen_request(&mut state, &mut requested_at); + + assert_eq!(state, XdgFrozenFullscreenState::Inactive); + assert_eq!(requested_at, None); + assert_eq!( + xdg_frozen_fullscreen_timeout( + state == XdgFrozenFullscreenState::PendingConfigure, + requested_at, + start + XDG_FROZEN_FULLSCREEN_TIMEOUT, + ), + None + ); + } } diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index f37c62ea2..64f2749ec 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -1,6 +1,10 @@ use std::time::Instant; +use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; use crate::backend::wayland::toolbar::hit::HitRegion; +use crate::backend::wayland::zoom::ZoomWaiterRegistry; + +use super::region_capture::ActiveScreenRegion; use super::capture::OverlayCaptureBarrier; @@ -136,6 +140,10 @@ pub struct StateData { pub(super) pending_activation_token: Option, pub(super) startup_activation_token: Option, pub(super) pending_freeze_on_start: bool, + pub(super) screen_acquisition: ScreenAcquisitionRegistry, + pub(super) zoom_waiter: ZoomWaiterRegistry, + pub(super) active_screen_region: Option, + pub(super) next_screen_region_generation: u64, pub(super) frozen_enabled: bool, pub(super) has_seen_surface_enter: bool, pub(super) preferred_output_identity: Option, @@ -239,6 +247,10 @@ impl StateData { pending_activation_token: None, startup_activation_token: None, pending_freeze_on_start: false, + screen_acquisition: ScreenAcquisitionRegistry::default(), + zoom_waiter: ZoomWaiterRegistry::default(), + active_screen_region: None, + next_screen_region_generation: 1, frozen_enabled: false, has_seen_surface_enter: false, preferred_output_identity: None, diff --git a/src/backend/wayland/state/eyedropper.rs b/src/backend/wayland/state/eyedropper.rs index b01c0d8d6..9a169f9f5 100644 --- a/src/backend/wayland/state/eyedropper.rs +++ b/src/backend/wayland/state/eyedropper.rs @@ -1,4 +1,6 @@ +use crate::backend::wayland::acquisition::{ScreenAcquisitionOutcome, ScreenAcquisitionOwner}; use crate::backend::wayland::frozen::FrozenImage; +use crate::backend::wayland::zoom::ZoomWaiterOwner; use crate::draw::Color; use crate::input::state::EyedropperCaptureSource; use crate::input::state::{Toast, ToastPriority}; @@ -6,7 +8,7 @@ use crate::input::state::{Toast, ToastPriority}; use super::WaylandState; use super::screen_image::{ DisplayedScreenImage, ScreenSourceEntry, displayed_screen_image, image_point_for_screen_point, - screen_source_entry, + screen_source_entry, screen_source_token, }; fn sample_at(image: &FrozenImage, image_x: f64, image_y: f64) -> Option { @@ -73,14 +75,25 @@ impl WaylandState { self.frozen_enabled(), ); match decision { - ScreenSourceEntry::Activate => self.activate_eyedropper_sampler(false), - ScreenSourceEntry::WaitForZoom => self - .input_state - .set_eyedropper_pending_capture(EyedropperCaptureSource::Zoom), + ScreenSourceEntry::Activate => self.activate_eyedropper_sampler(None), + ScreenSourceEntry::WaitForZoom => { + if self.wait_for_current_zoom_capture(ZoomWaiterOwner::Eyedropper) { + self.input_state + .set_eyedropper_pending_capture(EyedropperCaptureSource::Zoom); + } else { + self.report_zoom_image_unavailable(); + } + } ScreenSourceEntry::AutoFreeze => { - self.input_state - .set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); - self.input_state.request_frozen_toggle(); + match self.request_screen_acquisition(ScreenAcquisitionOwner::Eyedropper) { + Ok(_) => self + .input_state + .set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen), + Err(_) => self.report_terminal( + ScreenAcquisitionOwner::Eyedropper, + &ScreenAcquisitionOutcome::Unavailable, + ), + } } ScreenSourceEntry::RefuseWhileZoomedOnSolidBoard => { self.input_state.push_toast( @@ -106,11 +119,21 @@ impl WaylandState { ); } ScreenSourceEntry::ZoomImageUnavailable => { - self.input_state.push_toast(ToastPriority::Info, "eyedropper", Toast::warning("Screen eyedropper is unavailable because zoom has no captured screen image.")); + self.report_zoom_image_unavailable(); } } } + fn report_zoom_image_unavailable(&mut self) { + self.input_state.push_toast( + ToastPriority::Info, + "eyedropper", + Toast::warning( + "Screen eyedropper is unavailable because zoom has no captured screen image.", + ), + ); + } + fn background_image_source(&self) -> Option> { displayed_screen_image( &self.zoom, @@ -122,28 +145,25 @@ impl WaylandState { pub(in crate::backend::wayland) fn finish_pending_eyedropper_capture( &mut self, capture_source: EyedropperCaptureSource, + owned_frozen_generation: Option, ) { if self.input_state.eyedropper_state().pending_source() != Some(capture_source) { return; } if self.background_image_source().is_some() { - self.activate_eyedropper_sampler(matches!( - capture_source, - EyedropperCaptureSource::Frozen - )); + self.activate_eyedropper_sampler(owned_frozen_generation); } else { - self.cancel_eyedropper(); - self.input_state - .report_eyedropper_capture_failure_if_unreported(); + self.cancel_eyedropper_ui_only(); } } /// Arm the screen sampler. Retires an in-flight stylus contact for the same /// reason OCR does: from here the modal swallows the tip-up that would /// otherwise end it. - fn activate_eyedropper_sampler(&mut self, auto_froze: bool) { + fn activate_eyedropper_sampler(&mut self, owned_frozen_generation: Option) { self.retire_stylus_contact(); - self.input_state.activate_eyedropper(auto_froze); + self.input_state + .activate_eyedropper(owned_frozen_generation); } pub(in crate::backend::wayland) fn update_eyedropper_hover(&mut self, x: f64, y: f64) { @@ -187,12 +207,14 @@ impl WaylandState { x: f64, y: f64, ) -> (f64, f64) { - image_point_for_screen_point( + let token = screen_source_token( source, &self.zoom, + &self.frozen, (self.surface.width(), self.surface.height()), - (x, y), - ) + ); + let point = image_point_for_screen_point(&token, (x, y)); + (point.x, point.y) } pub(in crate::backend::wayland) fn render_eyedropper_loupe( @@ -201,11 +223,6 @@ impl WaylandState { screen_width: u32, screen_height: u32, ) { - const PIXELS: i32 = 11; - const CELL: f64 = 8.0; - const GAP: f64 = 18.0; - const LABEL_HEIGHT: f64 = 24.0; - let Some((pointer_x, pointer_y)) = self.input_state.eyedropper_state().hover() else { return; }; @@ -213,106 +230,30 @@ impl WaylandState { return; }; let (image_x, image_y) = self.eyedropper_image_coords(&source, pointer_x, pointer_y); - let center_x = image_x.floor(); - let center_y = image_y.floor(); - let grid_size = f64::from(PIXELS) * CELL; - let panel_w = grid_size + 12.0; - let panel_h = grid_size + LABEL_HEIGHT + 12.0; - let mut panel_x = pointer_x + GAP; - let mut panel_y = pointer_y + GAP; - if panel_x + panel_w > f64::from(screen_width) { - panel_x = pointer_x - GAP - panel_w; - } - if panel_y + panel_h > f64::from(screen_height) { - panel_y = pointer_y - GAP - panel_h; - } - panel_x = panel_x.clamp(4.0, (f64::from(screen_width) - panel_w - 4.0).max(4.0)); - panel_y = panel_y.clamp(4.0, (f64::from(screen_height) - panel_h - 4.0).max(4.0)); - - let _ = ctx.save(); - ctx.set_source_rgba(0.04, 0.05, 0.07, 0.96); - ctx.rectangle(panel_x, panel_y, panel_w, panel_h); - let _ = ctx.fill(); - - let grid_x = panel_x + 6.0; - let grid_y = panel_y + 6.0; - let half = PIXELS / 2; - for row in 0..PIXELS { - for col in 0..PIXELS { - if let Some(color) = sample_at( - source.image, - center_x + f64::from(col - half), - center_y + f64::from(row - half), - ) { - ctx.set_source_rgb(color.r, color.g, color.b); - ctx.rectangle( - grid_x + f64::from(col) * CELL, - grid_y + f64::from(row) * CELL, - CELL, - CELL, - ); - let _ = ctx.fill(); - } - } - } - - let center = f64::from(half) * CELL; - ctx.set_source_rgb(1.0, 1.0, 1.0); - ctx.set_line_width(2.0); - ctx.rectangle(grid_x + center, grid_y + center, CELL, CELL); - let _ = ctx.stroke(); - ctx.set_source_rgb(0.0, 0.0, 0.0); - ctx.set_line_width(1.0); - ctx.rectangle( - grid_x + center + 2.0, - grid_y + center + 2.0, - CELL - 4.0, - CELL - 4.0, + let layout = crate::ui::compute_eyedropper_loupe_layout( + (pointer_x, pointer_y), + (screen_width, screen_height), ); - let _ = ctx.stroke(); - - if let Some(color) = sample_at(source.image, center_x, center_y) { - let hex = format!( - "#{:02X}{:02X}{:02X}", - (color.r * 255.0).round() as u8, - (color.g * 255.0).round() as u8, - (color.b * 255.0).round() as u8 - ); - let swatch = 14.0; - let label_y = grid_y + grid_size + 5.0; - ctx.set_source_rgb(color.r, color.g, color.b); - ctx.rectangle(grid_x, label_y, swatch, swatch); - let _ = ctx.fill(); - ctx.set_source_rgb(1.0, 1.0, 1.0); - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); - ctx.set_font_size(12.0); - ctx.move_to(grid_x + swatch + 7.0, label_y + 12.0); - let _ = ctx.show_text(&hex); - } - - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.65); - ctx.set_line_width(1.0); - ctx.rectangle(panel_x + 0.5, panel_y + 0.5, panel_w - 1.0, panel_h - 1.0); - let _ = ctx.stroke(); - let _ = ctx.restore(); + crate::ui::render_eyedropper_loupe(ctx, layout, (image_x, image_y), |x, y| { + sample_at(source.image, x, y) + }); } pub(in crate::backend::wayland) fn cancel_eyedropper(&mut self) -> bool { let eyedropper_state = self.input_state.eyedropper_state(); let was_active = eyedropper_state.is_engaged(); - let pending_source = eyedropper_state.pending_source(); - let auto_froze = self.input_state.cancel_eyedropper(); - if auto_froze { - self.restore_xdg_after_frozen(); - if pending_source == Some(EyedropperCaptureSource::Frozen) - && self.frozen.is_in_progress() - { - self.frozen.cancel(&mut self.input_state); - self.exit_overlay_suppression(super::OverlaySuppression::Frozen); - } else { - self.frozen.unfreeze(&mut self.input_state); - } - } + let pending_acquisition = (eyedropper_state.pending_source() + == Some(EyedropperCaptureSource::Frozen)) + .then(|| self.screen_acquisition_slot()) + .flatten() + .filter(|record| record.owner == ScreenAcquisitionOwner::Eyedropper) + .map(|record| record.id); + let owned_generation = eyedropper_state.owned_frozen_generation(); + self.cancel_modal_owner_resources( + ScreenAcquisitionOwner::Eyedropper, + pending_acquisition, + owned_generation, + ); was_active } } diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 1ac8ee751..44c2e9586 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -5,19 +5,22 @@ //! OCR releases, and a user-owned freeze or zoom survives untouched. Nothing //! here mutates annotations, history, boards, sessions, or the active tool. -use crate::input::state::{OcrCaptureSource, OcrInputSource, Toast, ToastPriority}; +use crate::backend::wayland::acquisition::{ScreenAcquisitionOutcome, ScreenAcquisitionOwner}; +use crate::backend::wayland::zoom::ZoomWaiterOwner; +use crate::input::state::{ + RegionInputSource, RegionPurposeTag, ScreenCaptureSource, Toast, ToastPriority, +}; use crate::ocr::{OcrFailure, OcrLanguages, OcrPoll, OcrRequest, OcrSubmitError, OcrSuccess}; use super::WaylandState; +use super::region_capture::{ + ActiveScreenRegion, FreezeOwnership, RegionSelectionFinalize, finalize_region_selection_event, +}; use super::screen_image::{ CropError, DisplayedScreenImage, ScreenSourceEntry, copy_image_rect, displayed_screen_image, - image_rect_for_screen_rect, screen_source_entry, + screen_source_entry, }; -/// Drags below this many logical pixels on either axis are treated as a stray -/// click and never reach the engine. -const MIN_REGION_LOGICAL_PIXELS: f64 = 4.0; - const TOAST_SOURCE: &str = "ocr"; impl WaylandState { @@ -28,7 +31,7 @@ impl WaylandState { if !self.input_state.take_pending_ocr_request() { return; } - if self.input_state.ocr_state().is_engaged() { + if self.input_state.region_is_engaged() { // A second invocation while the selector is up cancels it, matching // the eyedropper's toggle behavior. self.cancel_ocr(); @@ -71,6 +74,7 @@ impl WaylandState { // wait would commit the cancelled stroke's peak pressure to the tool. self.retire_stylus_contact(); + let generation = self.next_screen_region_generation(); match screen_source_entry( self.ocr_screen_source().is_some(), self.input_state.board_is_transparent(), @@ -78,15 +82,34 @@ impl WaylandState { self.zoom.active, self.frozen_enabled(), ) { - ScreenSourceEntry::Activate => self.activate_ocr_selector(false), + ScreenSourceEntry::Activate => { + self.activate_ocr_selector(generation, FreezeOwnership::PreExisting); + } ScreenSourceEntry::WaitForZoom => { - self.input_state - .set_ocr_pending_capture(OcrCaptureSource::Zoom); + if self.wait_for_current_zoom_capture(ZoomWaiterOwner::Ocr) { + self.set_pending_screen_region( + RegionPurposeTag::Ocr, + generation, + ScreenCaptureSource::Zoom, + None, + ); + } else { + self.report_ocr_zoom_image_unavailable(); + } } ScreenSourceEntry::AutoFreeze => { - self.input_state - .set_ocr_pending_capture(OcrCaptureSource::Frozen); - self.input_state.request_frozen_toggle(); + match self.request_screen_acquisition(ScreenAcquisitionOwner::Ocr) { + Ok(acquisition) => self.set_pending_screen_region( + RegionPurposeTag::Ocr, + generation, + ScreenCaptureSource::Frozen, + Some(acquisition), + ), + Err(_) => self.report_terminal( + ScreenAcquisitionOwner::Ocr, + &ScreenAcquisitionOutcome::Unavailable, + ), + } } ScreenSourceEntry::RefuseWhileZoomedOnSolidBoard | ScreenSourceEntry::RefuseSolidBoard => { @@ -109,17 +132,21 @@ impl WaylandState { ); } ScreenSourceEntry::ZoomImageUnavailable => { - self.input_state.push_toast( - ToastPriority::Info, - TOAST_SOURCE, - Toast::warning( - "Screen text recognition is unavailable because zoom has no captured screen image.", - ), - ); + self.report_ocr_zoom_image_unavailable(); } } } + fn report_ocr_zoom_image_unavailable(&mut self) { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning( + "Screen text recognition is unavailable because zoom has no captured screen image.", + ), + ); + } + fn ocr_screen_source(&self) -> Option> { displayed_screen_image( &self.zoom, @@ -130,16 +157,37 @@ impl WaylandState { pub(in crate::backend::wayland) fn finish_pending_ocr_capture( &mut self, - capture_source: OcrCaptureSource, + capture_source: ScreenCaptureSource, + installed_generation: u64, ) { - if self.input_state.ocr_state().pending_source() != Some(capture_source) { + let Some(region) = self.data.active_screen_region else { + return; + }; + let generation = region.generation(); + let waiting = matches!( + (region, capture_source), + ( + ActiveScreenRegion::PendingZoom { .. }, + ScreenCaptureSource::Zoom + ) | ( + ActiveScreenRegion::PendingFrozen { .. }, + ScreenCaptureSource::Frozen + ) + ); + if !waiting { return; } if self.ocr_screen_source().is_some() { - self.activate_ocr_selector(matches!(capture_source, OcrCaptureSource::Frozen)); + let ownership = if capture_source == ScreenCaptureSource::Frozen { + FreezeOwnership::PickerOwned { + image_generation: installed_generation, + } + } else { + FreezeOwnership::PreExisting + }; + self.activate_ocr_selector(generation, ownership); } else { - self.cancel_ocr(); - self.input_state.report_ocr_capture_failure_if_unreported(); + self.cancel_ocr_ui_only(); } } @@ -151,34 +199,28 @@ impl WaylandState { /// separated: a capture that lands seconds after the request arms the /// selector against a gesture that started during the wait, not the one the /// request itself cancelled. - fn activate_ocr_selector(&mut self, auto_froze: bool) { + fn activate_ocr_selector(&mut self, generation: u64, ownership: FreezeOwnership) { self.retire_stylus_contact(); - self.input_state.activate_ocr(auto_froze); + if !self.activate_screen_region(RegionPurposeTag::Ocr, generation, ownership) { + self.clear_screen_region_ui_only(); + } } /// Leave OCR selection, releasing only a freeze OCR created itself. pub(in crate::backend::wayland) fn cancel_ocr(&mut self) -> bool { - let state = self.input_state.ocr_state(); - let was_engaged = state.is_engaged(); - let pending_source = state.pending_source(); - let auto_froze = self.input_state.cancel_ocr(); - self.release_ocr_capture(auto_froze, pending_source); - was_engaged - } - - /// Give back the temporary freeze OCR created. A user-owned freeze or an - /// active zoom is never touched here. - fn release_ocr_capture(&mut self, auto_froze: bool, pending_source: Option) { - if !auto_froze { - return; - } - self.restore_xdg_after_frozen(); - if pending_source == Some(OcrCaptureSource::Frozen) && self.frozen.is_in_progress() { - self.frozen.cancel(&mut self.input_state); - self.exit_overlay_suppression(super::OverlaySuppression::Frozen); - } else { - self.frozen.unfreeze(&mut self.input_state); - } + let Some(region) = self.data.active_screen_region else { + self.clear_zoom_waiter_for(ZoomWaiterOwner::Ocr); + self.input_state.cancel_region_ui_only(); + return false; + }; + let pending_acquisition = region.pending_acquisition(); + let owned_generation = region.owned_frozen_generation(); + self.cancel_modal_owner_resources( + ScreenAcquisitionOwner::Ocr, + pending_acquisition, + owned_generation, + ); + true } /// Dismiss the selector because a toolbar interaction took over. @@ -197,38 +239,23 @@ impl WaylandState { /// A display or source change invalidates the pixels the user is selecting. pub(in crate::backend::wayland) fn cancel_ocr_if_source_missing(&mut self) { - if self.input_state.ocr_is_active() && self.ocr_screen_source().is_none() { + if region_source_is_missing( + self.input_state.region_is_active(), + self.ocr_screen_source().is_some(), + ) { self.cancel_ocr(); } } - pub(in crate::backend::wayland) fn begin_ocr_selection( - &mut self, - owner: OcrInputSource, - x: f64, - y: f64, - ) -> bool { - self.input_state.start_ocr_selection(owner, (x, y)) - } - - pub(in crate::backend::wayland) fn update_ocr_selection( - &mut self, - source: OcrInputSource, - x: f64, - y: f64, - ) { - self.input_state.update_ocr_selection(source, (x, y)); - } - /// Discard a region because the device dragging it went away — the pen left /// proximity, the touch sequence was cancelled. Devices that are not /// dragging have nothing to withdraw, so this leaves the selector armed for /// whichever one is. - pub(in crate::backend::wayland) fn cancel_ocr_selection_from( + pub(in crate::backend::wayland) fn cancel_region_selection_from( &mut self, - source: OcrInputSource, + source: RegionInputSource, ) -> bool { - if !self.input_state.ocr_selection_is_owned_by(source) { + if !self.input_state.region_selection_is_owned_by(source) { return false; } self.cancel_ocr() @@ -236,34 +263,32 @@ impl WaylandState { /// End the drag: own the selected pixels, release an OCR-created freeze, /// and submit. Returns whether `source` had a region drag to finish. - pub(in crate::backend::wayland) fn finish_ocr_selection( + pub(in crate::backend::wayland) fn finish_region_selection( &mut self, - source: OcrInputSource, + source: RegionInputSource, x: f64, y: f64, ) -> bool { - // A release from a device that is not dragging must not submit — or - // even see — the region another one is still drawing. - if !self.input_state.ocr_selection_is_owned_by(source) { - return false; - } - self.input_state.update_ocr_selection(source, (x, y)); - let Some(selection) = self.input_state.ocr_state().selection() else { - return false; + let rect = match finalize_region_selection_event( + &mut self.data.active_screen_region, + &mut self.input_state, + source, + (x, y), + ) { + RegionSelectionFinalize::NotOwned => return false, + RegionSelectionFinalize::Rearmed => return true, + RegionSelectionFinalize::Selected(rect) => rect, }; // The crop is taken while the capture is still held: releasing first // would leave the worker reading pixels that no longer exist. - match self.crop_ocr_selection(selection.start, selection.end) { - Ok(None) => self.input_state.rearm_ocr_selection(), - Ok(Some(pixels)) => { - let auto_froze = self.input_state.cancel_ocr(); - self.release_ocr_capture(auto_froze, None); + match self.crop_ocr_selection(rect) { + Ok(pixels) => { + self.cancel_ocr(); self.submit_ocr_request(pixels); } Err(message) => { - let auto_froze = self.input_state.cancel_ocr(); - self.release_ocr_capture(auto_froze, None); + self.cancel_ocr(); self.input_state.push_toast( ToastPriority::Info, TOAST_SOURCE, @@ -274,44 +299,25 @@ impl WaylandState { true } - /// `Ok(None)` means the drag was too small to be a region — a stray click, - /// not a failure worth a toast. fn crop_ocr_selection( &self, - start: (f64, f64), - end: (f64, f64), - ) -> Result, &'static str> { - if (end.0 - start.0).abs() < MIN_REGION_LOGICAL_PIXELS - || (end.1 - start.1).abs() < MIN_REGION_LOGICAL_PIXELS - { - return Ok(None); - } + rect: crate::screen_pixels::ImagePixelRect, + ) -> Result { let Some(source) = self.ocr_screen_source() else { return Err("The screen image for text recognition is no longer available."); }; - let Some(rect) = image_rect_for_screen_rect( - &source, - &self.zoom, - (self.surface.width(), self.surface.height()), - start, - end, - ) else { - return Ok(None); - }; - copy_image_rect(source.image, rect) - .map(Some) - .map_err(|error| match error { - CropError::Empty => "That selection has no screen pixels.", - CropError::OutOfBounds => "Could not read that region of the screen image.", - }) + copy_image_rect(source.image, rect).map_err(|error| match error { + CropError::Empty => "That selection has no screen pixels.", + CropError::OutOfBounds => "Could not read that region of the screen image.", + }) } - fn submit_ocr_request(&mut self, pixels: crate::ocr::OcrPixels) { + fn submit_ocr_request(&mut self, pixels: crate::screen_pixels::PackedArgb32) { let languages = OcrLanguages::from_validated(self.config.capture.resolved_ocr_languages()); log::debug!( "OCR submitting {}x{} crop for languages {}", - pixels.width, - pixels.height, + pixels.width(), + pixels.height(), languages.as_str() ); match self.ocr.try_submit(OcrRequest { pixels, languages }) { @@ -408,12 +414,14 @@ impl WaylandState { screen_width: u32, screen_height: u32, ) { - if !self.input_state.ocr_is_active() { + if !self.input_state.region_is_active() + || self.input_state.region_state().purpose() != Some(RegionPurposeTag::Ocr) + { return; } let width = f64::from(screen_width); let height = f64::from(screen_height); - let selection = self.input_state.ocr_state().selection(); + let selection = self.input_state.region_state().selection(); let _ = ctx.save(); ctx.set_source_rgba(0.02, 0.03, 0.05, 0.35); @@ -446,6 +454,10 @@ fn normalized_rect(start: (f64, f64), end: (f64, f64)) -> (f64, f64, f64, f64) { (x, y, (end.0 - start.0).abs(), (end.1 - start.1).abs()) } +fn region_source_is_missing(region_active: bool, source_present: bool) -> bool { + region_active && !source_present +} + /// Four bracket corners, the visual language a scan frame reads as. The arm /// length shrinks with the region so a small selection is not swallowed by it. fn draw_scan_corners(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64) { @@ -492,4 +504,11 @@ mod tests { assert!(arm >= 4.0); } } + + #[test] + fn active_region_cancels_only_when_its_screen_source_is_missing() { + assert!(region_source_is_missing(true, false)); + assert!(!region_source_is_missing(true, true)); + assert!(!region_source_is_missing(false, false)); + } } diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs new file mode 100644 index 000000000..50c827f78 --- /dev/null +++ b/src/backend/wayland/state/region_capture.rs @@ -0,0 +1,687 @@ +use crate::backend::wayland::acquisition::ScreenAcquisitionId; +use crate::input::state::{ + RegionInputSource, RegionPurposeTag, RegionSelectUiState, ScreenCaptureSource, +}; +use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge, pixel_span}; + +use super::WaylandState; +use super::screen_image::{ScreenSourceToken, image_point_for_screen_point, screen_source_token}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FreezeOwnership { + PreExisting, + PickerOwned { image_generation: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum ActiveScreenRegion { + PendingFrozen { + purpose: RegionPurposeTag, + generation: u64, + acquisition: ScreenAcquisitionId, + }, + PendingZoom { + purpose: RegionPurposeTag, + generation: u64, + }, + Ready { + purpose: RegionPurposeTag, + generation: u64, + source: ScreenSourceToken, + freeze_ownership: FreezeOwnership, + anchor: Option, + raw_edge: Option, + logical_anchor: Option<(f64, f64)>, + logical_edge: Option<(f64, f64)>, + }, +} + +impl ActiveScreenRegion { + pub const fn purpose(self) -> RegionPurposeTag { + match self { + Self::PendingFrozen { purpose, .. } + | Self::PendingZoom { purpose, .. } + | Self::Ready { purpose, .. } => purpose, + } + } + + pub const fn generation(self) -> u64 { + match self { + Self::PendingFrozen { generation, .. } + | Self::PendingZoom { generation, .. } + | Self::Ready { generation, .. } => generation, + } + } + + pub const fn pending_acquisition(self) -> Option { + match self { + Self::PendingFrozen { acquisition, .. } => Some(acquisition), + Self::PendingZoom { .. } | Self::Ready { .. } => None, + } + } + + pub fn waits_for_acquisition(self, id: ScreenAcquisitionId) -> bool { + matches!(self, Self::PendingFrozen { acquisition, .. } if acquisition == id) + } + + pub const fn owned_frozen_generation(self) -> Option { + match self { + Self::Ready { + freeze_ownership: FreezeOwnership::PickerOwned { image_generation }, + .. + } => Some(image_generation), + Self::PendingFrozen { .. } + | Self::PendingZoom { .. } + | Self::Ready { + freeze_ownership: FreezeOwnership::PreExisting, + .. + } => None, + } + } + + fn selection_rect(self) -> Option { + let Self::Ready { + purpose, + anchor: Some(anchor), + raw_edge: Some(raw_edge), + logical_anchor, + logical_edge, + source, + .. + } = self + else { + return None; + }; + if let Some(minimum) = purpose.selection_policy().min_submit_logical_px() { + let (Some(logical_anchor), Some(logical_edge)) = (logical_anchor, logical_edge) else { + return None; + }; + if (logical_edge.0 - logical_anchor.0).abs() < minimum + || (logical_edge.1 - logical_anchor.1).abs() < minimum + { + return None; + } + } + pixel_span(anchor, raw_edge, source.image_size)? + .try_into() + .ok() + } + + fn begin_selection(&mut self, logical: (f64, f64)) -> bool { + let Self::Ready { + purpose, + source, + anchor, + raw_edge, + logical_anchor, + logical_edge, + .. + } = self + else { + return false; + }; + if anchor.is_some() + || raw_edge.is_some() + || logical_anchor.is_some() + || logical_edge.is_some() + { + return false; + } + let Some(mapped) = clamp_edge( + image_point_for_screen_point(source, logical), + source.image_size, + ) else { + return false; + }; + debug_assert_eq!(*purpose, RegionPurposeTag::Ocr); + *anchor = Some(mapped); + *raw_edge = Some(mapped); + *logical_anchor = Some(logical); + *logical_edge = Some(logical); + true + } + + fn update_endpoint(&mut self, logical: (f64, f64)) -> bool { + let Self::Ready { + source, + raw_edge, + logical_edge, + .. + } = self + else { + return false; + }; + let Some(mapped) = clamp_edge( + image_point_for_screen_point(source, logical), + source.image_size, + ) else { + return false; + }; + *raw_edge = Some(mapped); + *logical_edge = Some(logical); + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RegionSelectionFinalize { + NotOwned, + Rearmed, + Selected(ImagePixelRect), +} + +fn begin_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), +) -> bool { + if !backend + .as_mut() + .is_some_and(|region| region.begin_selection(logical)) + { + return false; + } + input_state.start_region_selection(owner, logical) +} + +fn update_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), +) { + if !input_state.region_selection_is_owned_by(owner) { + return; + } + if backend + .as_mut() + .is_some_and(|region| region.update_endpoint(logical)) + { + input_state.update_region_selection(owner, logical); + } +} + +fn rearm_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, +) { + if let Some(ActiveScreenRegion::Ready { + anchor, + raw_edge, + logical_anchor, + logical_edge, + .. + }) = backend.as_mut() + { + *anchor = None; + *raw_edge = None; + *logical_anchor = None; + *logical_edge = None; + } + input_state.rearm_region_selection(); +} + +pub(super) fn finalize_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), +) -> RegionSelectionFinalize { + if !input_state.region_selection_is_owned_by(owner) { + return RegionSelectionFinalize::NotOwned; + } + update_region_selection_event(backend, input_state, owner, logical); + let Some(rect) = backend + .as_ref() + .copied() + .and_then(ActiveScreenRegion::selection_rect) + else { + rearm_region_selection_event(backend, input_state); + return RegionSelectionFinalize::Rearmed; + }; + RegionSelectionFinalize::Selected(rect) +} + +impl WaylandState { + pub(super) fn next_screen_region_generation(&mut self) -> u64 { + let generation = self.data.next_screen_region_generation; + self.data.next_screen_region_generation = generation + .checked_add(1) + .expect("screen region generation space exhausted"); + generation + } + + pub(super) fn set_pending_screen_region( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + source: ScreenCaptureSource, + acquisition: Option, + ) { + self.data.active_screen_region = Some(match source { + ScreenCaptureSource::Frozen => ActiveScreenRegion::PendingFrozen { + purpose, + generation, + acquisition: acquisition.expect("frozen region wait has an acquisition id"), + }, + ScreenCaptureSource::Zoom => ActiveScreenRegion::PendingZoom { + purpose, + generation, + }, + }); + self.input_state + .set_region_pending_capture(purpose, generation, source); + self.debug_assert_screen_region_invariant(); + } + + pub(super) fn activate_screen_region( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + freeze_ownership: FreezeOwnership, + ) -> bool { + let Some(source) = super::screen_image::displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) else { + return false; + }; + let token = screen_source_token( + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ); + self.data.active_screen_region = Some(ActiveScreenRegion::Ready { + purpose, + generation, + source: token, + freeze_ownership, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + }); + self.input_state.activate_region(purpose, generation); + self.debug_assert_screen_region_invariant(); + true + } + + pub(super) fn clear_screen_region_ui_only(&mut self) { + self.data.active_screen_region = None; + self.input_state.cancel_region_ui_only(); + self.debug_assert_screen_region_invariant(); + } + + pub(in crate::backend::wayland) fn begin_region_selection( + &mut self, + owner: RegionInputSource, + x: f64, + y: f64, + ) -> bool { + begin_region_selection_event( + &mut self.data.active_screen_region, + &mut self.input_state, + owner, + (x, y), + ) + } + + pub(in crate::backend::wayland) fn update_region_selection( + &mut self, + owner: RegionInputSource, + x: f64, + y: f64, + ) { + update_region_selection_event( + &mut self.data.active_screen_region, + &mut self.input_state, + owner, + (x, y), + ); + } + + pub(super) fn debug_assert_screen_region_invariant(&self) { + debug_assert!(screen_region_invariant( + self.data.active_screen_region, + self.input_state.region_state(), + )); + } +} + +fn screen_region_invariant(backend: Option, ui: RegionSelectUiState) -> bool { + match (backend, ui) { + (None, RegionSelectUiState::Inactive) => true, + (None, _) | (Some(_), RegionSelectUiState::Inactive) => false, + (Some(region), ui) => { + ui.generation() == Some(region.generation()) && ui.purpose() == Some(region.purpose()) + } + } +} + +pub(super) fn owned_generation_is_current( + expected: u64, + current: u64, + frozen_active: bool, +) -> bool { + frozen_active && expected == current +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + + #[test] + fn pending_and_ready_region_state_preserve_generation_and_freeze_ownership() { + let pending = ActiveScreenRegion::PendingZoom { + purpose: RegionPurposeTag::Ocr, + generation: 9, + }; + assert_eq!(pending.generation(), 9); + assert_eq!(pending.purpose(), RegionPurposeTag::Ocr); + assert_eq!(pending.owned_frozen_generation(), None); + + let ready = ActiveScreenRegion::Ready { + purpose: RegionPurposeTag::Ocr, + generation: 9, + source: ScreenSourceToken { + output_id: 1, + output_layout_generation: 0, + kind: super::super::screen_image::ScreenImageKind::Frozen, + image_generation: 44, + image_size: (100, 80), + stride: 400, + surface: (100, 80), + output_scale: 1, + output_transform: wayland_client::protocol::wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + }, + freeze_ownership: FreezeOwnership::PickerOwned { + image_generation: 44, + }, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + }; + assert_eq!(ready.generation(), pending.generation()); + assert_eq!(ready.owned_frozen_generation(), Some(44)); + } + + #[test] + fn backend_and_ui_generation_must_match() { + let backend = Some(ActiveScreenRegion::PendingZoom { + purpose: RegionPurposeTag::Ocr, + generation: 7, + }); + assert!(screen_region_invariant( + backend, + RegionSelectUiState::PendingCapture { + purpose: RegionPurposeTag::Ocr, + generation: 7, + source: ScreenCaptureSource::Zoom, + } + )); + assert!(!screen_region_invariant( + backend, + RegionSelectUiState::PendingCapture { + purpose: RegionPurposeTag::Ocr, + generation: 8, + source: ScreenCaptureSource::Zoom, + } + )); + assert!(!screen_region_invariant( + backend, + RegionSelectUiState::Inactive + )); + } + + #[test] + fn armed_freeze_release_is_generation_checked_and_consumed_once() { + assert!(owned_generation_is_current(44, 44, true)); + assert!(!owned_generation_is_current(44, 45, true)); + assert!(!owned_generation_is_current(44, 44, false)); + + let mut owned = ocr_region(1.0); + if let ActiveScreenRegion::Ready { + freeze_ownership, .. + } = &mut owned + { + *freeze_ownership = FreezeOwnership::PickerOwned { + image_generation: 44, + }; + } + let mut armed = Some(owned); + let first = armed + .take() + .and_then(ActiveScreenRegion::owned_frozen_generation); + let second = armed + .take() + .and_then(ActiveScreenRegion::owned_frozen_generation); + assert_eq!(first, Some(44)); + assert_eq!( + second, None, + "a second cancellation has no region to release" + ); + } + + fn ocr_region(scale: f64) -> ActiveScreenRegion { + ActiveScreenRegion::Ready { + purpose: RegionPurposeTag::Ocr, + generation: 1, + source: ScreenSourceToken { + output_id: 1, + output_layout_generation: 0, + kind: super::super::screen_image::ScreenImageKind::Frozen, + image_generation: 1, + image_size: ((100.0 * scale) as u32, (80.0 * scale) as u32), + stride: (400.0 * scale) as i32, + surface: (100, 80), + output_scale: 1, + output_transform: wayland_client::protocol::wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + }, + freeze_ownership: FreezeOwnership::PreExisting, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + } + } + + #[test] + fn ocr_three_logical_pixels_rearms_but_exactly_four_submits() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((13.0, 23.0))); + assert_eq!(region.selection_rect(), None); + + assert!(region.update_endpoint((14.0, 24.0))); + assert_eq!( + region + .selection_rect() + .map(|rect| (rect.x(), rect.y(), rect.size())), + Some((10, 20, (4, 4))) + ); + } + + #[test] + fn ocr_release_endpoint_without_motion_matches_forward_and_reversed_scale_oracle() { + for (scale, expected_forward, expected_reversed) in [ + (1.0, (10, 20, (5, 5)), (10, 20, (5, 5))), + (2.0, (20, 41, (9, 8)), (20, 41, (9, 8))), + (1.5, (15, 30, (7, 7)), (15, 30, (7, 7))), + ] { + let mut forward = ocr_region(scale); + assert!(forward.begin_selection((10.25, 20.5))); + assert!(forward.update_endpoint((14.25, 24.5))); + let forward_rect = forward.selection_rect().unwrap(); + assert_eq!( + (forward_rect.x(), forward_rect.y(), forward_rect.size()), + expected_forward, + "forward at scale {scale}" + ); + + let mut reversed = ocr_region(scale); + assert!(reversed.begin_selection((14.25, 24.5))); + assert!(reversed.update_endpoint((10.25, 20.5))); + let reversed_rect = reversed.selection_rect().unwrap(); + assert_eq!( + (reversed_rect.x(), reversed_rect.y(), reversed_rect.size()), + expected_reversed, + "reversed at scale {scale}" + ); + } + } + + #[test] + fn ocr_release_replaces_the_last_motion_endpoint() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((18.0, 32.0))); + assert_eq!(region.selection_rect().unwrap().size(), (8, 12)); + + assert!(region.update_endpoint((14.0, 24.0))); + assert_eq!(region.selection_rect().unwrap().size(), (4, 4)); + } + + #[test] + fn second_device_press_cannot_replace_an_in_progress_backend_region() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((18.0, 28.0))); + + assert!(!region.begin_selection((50.0, 60.0))); + assert_eq!( + region + .selection_rect() + .map(|rect| (rect.x(), rect.y(), rect.size())), + Some((10, 20, (8, 8))) + ); + } + + #[test] + fn ocr_policy_keeps_shift_held_drag_rectangular() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((14.0, 28.0))); + let rect = region.selection_rect().unwrap(); + assert_eq!(rect.size(), (4, 8)); + assert!(!RegionPurposeTag::Ocr.selection_policy().allow_square()); + } + + #[test] + fn production_ocr_event_adapter_uses_release_endpoint_at_every_scale() { + for (scale, expected) in [ + (1.0, (10, 20, (5, 5))), + (2.0, (20, 41, (9, 8))), + (1.5, (15, 30, (7, 7))), + ] { + for reversed in [false, true] { + for has_motion in [false, true] { + let (press, release) = if reversed { + ((14.25, 24.5), (10.25, 20.5)) + } else { + ((10.25, 20.5), (14.25, 24.5)) + }; + let mut backend = Some(ocr_region(scale)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + press, + )); + if has_motion { + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (30.0, 35.0), + ); + } + let RegionSelectionFinalize::Selected(rect) = finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + release, + ) else { + panic!( + "release must submit at scale={scale} reversed={reversed} motion={has_motion}" + ); + }; + + assert_eq!( + (rect.x(), rect.y(), rect.size()), + expected, + "scale={scale} reversed={reversed} motion={has_motion}" + ); + assert_eq!( + input + .region_state() + .selection() + .map(|selection| selection.end), + Some(release), + "the UI preview and crop adapter diverged" + ); + } + } + } + } + + #[test] + fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_policy() { + let mut backend = Some(ocr_region(1.0)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + input.sync_modifiers(true, false, false, false); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 20.0), + )); + let RegionSelectionFinalize::Selected(rect) = finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (14.0, 28.0), + ) else { + panic!("Shift-held OCR drag must submit"); + }; + assert_eq!(rect.size(), (4, 8)); + + let mut backend = Some(ocr_region(1.0)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 20.0), + )); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (13.0, 23.0), + ), + RegionSelectionFinalize::Rearmed + ); + assert!(matches!( + input.region_state(), + RegionSelectUiState::Armed { .. } + )); + } +} diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs index 6138277cd..8999d9efe 100644 --- a/src/backend/wayland/state/screen_image.rs +++ b/src/backend/wayland/state/screen_image.rs @@ -5,8 +5,11 @@ //! screenshot, so source resolution, the logical→image mapping, and the checked //! rectangle copy live here instead of inside either feature. -use crate::backend::wayland::frozen::{FrozenImage, FrozenState}; +use crate::backend::wayland::frozen::{FrozenImage, FrozenState, ScreenImageProvenance}; use crate::backend::wayland::zoom::ZoomState; +use crate::screen_pixels::{ImagePixelRect, ImagePoint, PixelSpan}; +use crate::util::Rect; +use wayland_client::protocol::wl_output; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ScreenImageKind { @@ -18,19 +21,26 @@ pub(super) enum ScreenImageKind { pub(super) struct DisplayedScreenImage<'a> { pub image: &'a FrozenImage, pub kind: ScreenImageKind, + pub provenance: ScreenImageProvenance, /// The renderer is painting this image through the zoom transform, so /// screen points must be un-zoomed before they are scaled into the image. pub zoom_transformed: bool, } -/// A rectangle in source-image pixels, guaranteed non-empty and in bounds of -/// the image it was resolved against. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct ImageRect { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct ScreenSourceToken { + pub output_id: u32, + pub output_layout_generation: u64, + pub kind: ScreenImageKind, + pub image_generation: u64, + pub image_size: (u32, u32), + pub stride: i32, + pub surface: (u32, u32), + pub output_scale: i32, + pub output_transform: wl_output::Transform, + pub zoom_transformed: bool, + pub zoom_scale: f64, + pub zoom_view_offset: (f64, f64), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -92,72 +102,159 @@ pub(super) fn displayed_screen_image<'a>( if zoom.active { if let Some(image) = zoom.image() { - return Some(DisplayedScreenImage { - image, - kind: ScreenImageKind::Zoom, - zoom_transformed: true, - }); + return zoom + .image_provenance() + .map(|provenance| DisplayedScreenImage { + image, + kind: ScreenImageKind::Zoom, + provenance, + zoom_transformed: true, + }); } if let Some(image) = frozen.image() { - return Some(DisplayedScreenImage { - image, - kind: ScreenImageKind::Frozen, - zoom_transformed: true, - }); + return frozen + .image_provenance() + .map(|provenance| DisplayedScreenImage { + image, + kind: ScreenImageKind::Frozen, + provenance, + zoom_transformed: true, + }); } } - frozen.image().map(|image| DisplayedScreenImage { - image, - kind: ScreenImageKind::Frozen, - zoom_transformed: false, - }) + let image = frozen.image()?; + frozen + .image_provenance() + .map(|provenance| DisplayedScreenImage { + image, + kind: ScreenImageKind::Frozen, + provenance, + zoom_transformed: false, + }) } /// Map a logical screen point onto source-image pixel coordinates. The result /// is unclamped on purpose: samplers clamp to the nearest pixel, while region /// selection clips against the image bounds. -pub(super) fn image_point_for_screen_point( +pub(super) fn screen_source_token( source: &DisplayedScreenImage<'_>, zoom: &ZoomState, + frozen: &FrozenState, surface: (u32, u32), - point: (f64, f64), -) -> (f64, f64) { - let (world_x, world_y) = if source.zoom_transformed { - zoom.screen_to_world(point.0, point.1) +) -> ScreenSourceToken { + let provenance = source.provenance; + let (zoom_scale, zoom_view_offset) = if source.zoom_transformed { + (zoom.scale, zoom.view_offset) } else { - point + (1.0, (0.0, 0.0)) }; - ( - world_x * f64::from(source.image.width) / f64::from(surface.0).max(1.0), - world_y * f64::from(source.image.height) / f64::from(surface.1).max(1.0), - ) + ScreenSourceToken { + output_id: provenance.output_id, + output_layout_generation: provenance.output_layout_generation, + kind: source.kind, + image_generation: match source.kind { + ScreenImageKind::Zoom => zoom.image_generation(), + ScreenImageKind::Frozen => frozen.image_generation(), + }, + image_size: (source.image.width, source.image.height), + stride: source.image.stride, + surface, + output_scale: provenance.output_scale, + output_transform: provenance.output_transform, + zoom_transformed: source.zoom_transformed, + zoom_scale, + zoom_view_offset, + } } -/// Map a logical screen rectangle (given by any two opposite corners) onto a -/// non-empty in-bounds source-image rectangle, or `None` when the two corners -/// resolve to nothing the image can supply. -pub(super) fn image_rect_for_screen_rect( +#[allow(dead_code)] // Phase 0b compares the stored token before routing input. +pub(super) fn screen_source_is( + expected: &ScreenSourceToken, source: &DisplayedScreenImage<'_>, zoom: &ZoomState, + frozen: &FrozenState, surface: (u32, u32), - start: (f64, f64), - end: (f64, f64), -) -> Option { - let first = image_point_for_screen_point(source, zoom, surface, start); - let second = image_point_for_screen_point(source, zoom, surface, end); - image_rect_from_points(first, second, source.image.width, source.image.height) +) -> bool { + *expected == screen_source_token(source, zoom, frozen, surface) +} + +pub(super) fn image_point_for_screen_point( + token: &ScreenSourceToken, + point: (f64, f64), +) -> ImagePoint { + let (world_x, world_y) = if token.zoom_transformed { + ( + token.zoom_view_offset.0 + point.0 / token.zoom_scale, + token.zoom_view_offset.1 + point.1 / token.zoom_scale, + ) + } else { + point + }; + ImagePoint::new( + world_x * f64::from(token.image_size.0) / f64::from(token.surface.0).max(1.0), + world_y * f64::from(token.image_size.1) / f64::from(token.surface.1).max(1.0), + ) +} + +#[allow(dead_code)] // Phase 0b derives the region preview from image-space state. +pub(super) fn screen_point_for_image_point( + token: &ScreenSourceToken, + point: ImagePoint, +) -> (f64, f64) { + let world = ( + point.x * f64::from(token.surface.0) / f64::from(token.image_size.0).max(1.0), + point.y * f64::from(token.surface.1) / f64::from(token.image_size.1).max(1.0), + ); + if token.zoom_transformed { + ( + (world.0 - token.zoom_view_offset.0) * token.zoom_scale, + (world.1 - token.zoom_view_offset.1) * token.zoom_scale, + ) + } else { + world + } +} + +#[allow(dead_code)] // Phase 0b derives the region preview from image-space state. +pub(super) fn screen_rect_for_image_rect(token: &ScreenSourceToken, rect: ImagePixelRect) -> Rect { + let first = screen_point_for_image_point( + token, + ImagePoint::new(f64::from(rect.x()), f64::from(rect.y())), + ); + let second = screen_point_for_image_point( + token, + ImagePoint::new( + f64::from(rect.x() + rect.width()), + f64::from(rect.y() + rect.height()), + ), + ); + let left = first.0.min(second.0).floor() as i32; + let top = first.1.min(second.1).floor() as i32; + let right = first.0.max(second.0).ceil() as i32; + let bottom = first.1.max(second.1).ceil() as i32; + Rect::from_min_max(left, top, right, bottom) + .expect("a non-empty image rectangle must map to a non-empty screen rectangle") +} + +#[allow(dead_code)] // Phase 0b derives the selecting preview from a possibly empty span. +pub(super) fn screen_rect_for_pixel_span( + token: &ScreenSourceToken, + span: PixelSpan, +) -> Option { + Some(screen_rect_for_image_rect(token, span.try_into().ok()?)) } /// Clip two unordered image-space points into an integer pixel rectangle. /// Reversed drags, partly out-of-bounds drags, and non-finite coordinates all /// resolve deterministically here rather than at the call sites. +#[cfg(test)] fn image_rect_from_points( first: (f64, f64), second: (f64, f64), image_width: u32, image_height: u32, -) -> Option { +) -> Option { if image_width == 0 || image_height == 0 { return None; } @@ -178,12 +275,13 @@ fn image_rect_from_points( return None; } - Some(ImageRect { - x: left as u32, - y: top as u32, - width: (right - left) as u32, - height: (bottom - top) as u32, - }) + ImagePixelRect::new( + left as u32, + top as u32, + (right - left) as u32, + (bottom - top) as u32, + (image_width, image_height), + ) } /// Copy `rect` out of `source` into a tightly packed owned ARGB snapshot. @@ -192,42 +290,42 @@ fn image_rect_from_points( /// hand it straight to Cairo without re-deriving a stride. pub(super) fn copy_image_rect( source: &FrozenImage, - rect: ImageRect, -) -> Result { - if rect.width == 0 || rect.height == 0 || source.width == 0 || source.height == 0 { + rect: ImagePixelRect, +) -> Result { + if source.width == 0 || source.height == 0 { return Err(CropError::Empty); } if rect - .x - .checked_add(rect.width) + .x() + .checked_add(rect.width()) .ok_or(CropError::OutOfBounds)? > source.width || rect - .y - .checked_add(rect.height) + .y() + .checked_add(rect.height()) .ok_or(CropError::OutOfBounds)? > source.height { return Err(CropError::OutOfBounds); } let source_stride = usize::try_from(source.stride).map_err(|_| CropError::OutOfBounds)?; - let row_bytes = usize::try_from(rect.width) + let row_bytes = usize::try_from(rect.width()) .ok() .and_then(|width| width.checked_mul(4)) .ok_or(CropError::OutOfBounds)?; - let total_bytes = usize::try_from(rect.height) + let total_bytes = usize::try_from(rect.height()) .ok() .and_then(|height| height.checked_mul(row_bytes)) .ok_or(CropError::OutOfBounds)?; let stride = i32::try_from(row_bytes).map_err(|_| CropError::OutOfBounds)?; let mut data = Vec::with_capacity(total_bytes); - let start_column = usize::try_from(rect.x) + let start_column = usize::try_from(rect.x()) .ok() .and_then(|x| x.checked_mul(4)) .ok_or(CropError::OutOfBounds)?; - for row in 0..rect.height { - let source_row = usize::try_from(rect.y.saturating_add(row)) + for row in 0..rect.height() { + let source_row = usize::try_from(rect.y().saturating_add(row)) .ok() .and_then(|row| row.checked_mul(source_stride)) .ok_or(CropError::OutOfBounds)?; @@ -238,17 +336,14 @@ pub(super) fn copy_image_rect( data.extend_from_slice(source.data.get(start..end).ok_or(CropError::OutOfBounds)?); } - Ok(crate::ocr::OcrPixels { - width: rect.width, - height: rect.height, - stride, - data, - }) + crate::screen_pixels::PackedArgb32::new(rect.width(), rect.height(), stride, data) + .ok_or(CropError::OutOfBounds) } #[cfg(test)] mod tests { use super::*; + use crate::backend::wayland::frozen::ScreenImageProvenance; fn image(width: u32, height: u32, stride: i32, data: Vec) -> FrozenImage { FrozenImage { @@ -273,13 +368,42 @@ mod tests { ZoomState::new(None) } + fn provenance( + output_id: u32, + output_layout_generation: u64, + output_scale: i32, + output_transform: wl_output::Transform, + ) -> ScreenImageProvenance { + ScreenImageProvenance::new( + output_id, + output_layout_generation, + output_scale, + output_transform, + ) + .unwrap() + } + + fn install_frozen(frozen: &mut FrozenState, image: FrozenImage) { + frozen.set_image_with_provenance_for_test( + image, + provenance(1, 1, 1, wl_output::Transform::Normal), + ); + } + + fn install_zoom(zoom: &mut ZoomState, image: FrozenImage) { + zoom.set_image_with_provenance_for_test( + image, + provenance(1, 1, 1, wl_output::Transform::Normal), + ); + } + #[test] fn displayed_source_truth_table_matches_the_rendering_contract() { let mut zoom = zoom_state(); let mut frozen = FrozenState::new(None); assert!(displayed_screen_image(&zoom, &frozen, true).is_none()); - frozen.set_image(opaque(2, 2)); + install_frozen(&mut frozen, opaque(2, 2)); let source = displayed_screen_image(&zoom, &frozen, false).unwrap(); assert_eq!(source.kind, ScreenImageKind::Frozen); assert!(!source.zoom_transformed); @@ -290,7 +414,7 @@ mod tests { assert!(source.zoom_transformed); assert!(displayed_screen_image(&zoom, &frozen, false).is_none()); - zoom.set_image(opaque(2, 2)); + install_zoom(&mut zoom, opaque(2, 2)); let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); assert_eq!(source.kind, ScreenImageKind::Zoom); assert!(source.zoom_transformed); @@ -303,54 +427,285 @@ mod tests { #[test] fn unzoomed_points_scale_by_the_image_to_surface_ratio() { let mut frozen = FrozenState::new(None); - frozen.set_image(opaque(200, 100)); + install_frozen(&mut frozen, opaque(200, 100)); let zoom = zoom_state(); let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); - let point = image_point_for_screen_point(&source, &zoom, (100, 50), (50.0, 25.0)); - assert!((point.0 - 100.0).abs() < f64::EPSILON); - assert!((point.1 - 50.0).abs() < f64::EPSILON); + let token = screen_source_token(&source, &zoom, &frozen, (100, 50)); + let point = image_point_for_screen_point(&token, (50.0, 25.0)); + assert!((point.x - 100.0).abs() < f64::EPSILON); + assert!((point.y - 50.0).abs() < f64::EPSILON); } #[test] fn zoomed_points_use_the_same_transform_the_renderer_applied() { let mut zoom = zoom_state(); zoom.active = true; - zoom.set_image(opaque(100, 100)); + install_zoom(&mut zoom, opaque(100, 100)); zoom.scale = 2.0; zoom.view_offset = (10.0, 20.0); let frozen = FrozenState::new(None); let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); - let point = image_point_for_screen_point(&source, &zoom, (100, 100), (40.0, 60.0)); + let token = screen_source_token(&source, &zoom, &frozen, (100, 100)); + let point = image_point_for_screen_point(&token, (40.0, 60.0)); // screen_to_world(40, 60) = (10 + 20, 20 + 30) with a 1:1 image scale. - assert!((point.0 - 30.0).abs() < f64::EPSILON); - assert!((point.1 - 50.0).abs() < f64::EPSILON); + assert!((point.x - 30.0).abs() < f64::EPSILON); + assert!((point.y - 50.0).abs() < f64::EPSILON); } #[test] - fn reversed_drags_normalize_and_partly_offscreen_drags_clip() { - let rect = image_rect_from_points((30.0, 40.0), (10.0, 20.0), 100, 100).unwrap(); + fn image_and_screen_points_round_trip_at_native_integer_and_fractional_scale() { + for image_size in [(100, 50), (200, 100), (150, 75)] { + let mut frozen = FrozenState::new(None); + install_frozen(&mut frozen, opaque(image_size.0, image_size.1)); + let zoom = zoom_state(); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + let token = screen_source_token(&source, &zoom, &frozen, (100, 50)); + + let image_point = ImagePoint::new(23.5, 17.25); + let screen_point = screen_point_for_image_point(&token, image_point); + let round_trip = image_point_for_screen_point(&token, screen_point); + + assert!((round_trip.x - image_point.x).abs() < 1e-12); + assert!((round_trip.y - image_point.y).abs() < 1e-12); + } + } + + #[test] + fn token_records_the_frozen_outputs_complete_identity() { + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use wayland_client::protocol::wl_output; + + let mut frozen = FrozenState::new(None); + frozen.set_active_output(None, Some(42)); + frozen.set_active_geometry(OutputGeometry::update_from( + Some((0, 0)), + Some((100, 50)), + (100, 50), + 2, + wl_output::Transform::_90, + Some((100, 200)), + )); + frozen.set_image_with_provenance_for_test( + opaque(100, 200), + provenance(42, 1, 2, wl_output::Transform::_90), + ); + let zoom = zoom_state(); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + + let token = screen_source_token(&source, &zoom, &frozen, (100, 50)); + + assert_eq!(token.output_id, 42); + assert_eq!(token.output_layout_generation, 1); + assert_eq!(token.output_scale, 2); + assert_eq!(token.output_transform, wl_output::Transform::_90); + } + + #[test] + fn fractional_transformed_zoom_mapping_and_pixel_span_inverse_are_stable() { + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use crate::screen_pixels::pixel_span; + use crate::util::Rect; + use wayland_client::protocol::wl_output; + + let frozen = FrozenState::new(None); + let mut zoom = zoom_state(); + zoom.set_active_output(None, Some(7)); + zoom.set_active_geometry(OutputGeometry::update_from( + Some((0, 0)), + Some((200, 100)), + (200, 100), + 2, + wl_output::Transform::_90, + Some((300, 150)), + )); + zoom.active = true; + zoom.set_image_with_provenance_for_test( + opaque(300, 150), + provenance(7, 1, 2, wl_output::Transform::_90), + ); + zoom.scale = 2.0; + zoom.view_offset = (100.0, 50.0); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + let token = screen_source_token(&source, &zoom, &frozen, (200, 100)); + + assert_eq!(token.output_id, 7); + assert_eq!(token.output_layout_generation, 1); + assert_eq!(token.output_scale, 2); + assert_eq!(token.output_transform, wl_output::Transform::_90); + let screen_point = screen_point_for_image_point(&token, ImagePoint::new(180.0, 90.0)); + assert_eq!(screen_point, (40.0, 20.0)); assert_eq!( - rect, - ImageRect { - x: 10, - y: 20, - width: 20, - height: 20, - } + screen_rect_for_image_rect( + &token, + ImagePixelRect::new(150, 75, 30, 15, (300, 150)).unwrap(), + ), + Rect::new(0, 0, 40, 20).unwrap() ); - let clipped = image_rect_from_points((-50.0, -50.0), (10.5, 10.5), 100, 100).unwrap(); + let span = pixel_span( + ImagePoint::new(150.0, 75.0), + ImagePoint::new(180.0, 90.0), + token.image_size, + ) + .unwrap(); assert_eq!( - clipped, - ImageRect { - x: 0, - y: 0, - width: 11, - height: 11, - } + screen_rect_for_pixel_span(&token, span), + Rect::new(0, 0, 40, 20) ); + let empty = pixel_span( + ImagePoint::new(150.0, 75.0), + ImagePoint::new(150.0, 75.0), + token.image_size, + ) + .unwrap(); + assert_eq!(screen_rect_for_pixel_span(&token, empty), None); + } + + #[test] + fn source_provenance_survives_live_output_changes_and_detects_image_replacement() { + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use wayland_client::protocol::wl_output; + + let mut frozen = FrozenState::new(None); + frozen.set_active_output(None, Some(11)); + frozen.set_active_geometry(OutputGeometry::update_from( + Some((0, 0)), + Some((100, 50)), + (100, 50), + 1, + wl_output::Transform::Normal, + Some((100, 50)), + )); + frozen.set_image_with_provenance_for_test( + opaque(100, 50), + provenance(11, 1, 1, wl_output::Transform::Normal), + ); + let mut zoom = zoom_state(); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + let token = screen_source_token(&source, &zoom, &frozen, (100, 50)); + assert!(screen_source_is(&token, &source, &zoom, &frozen, (100, 50))); + assert!(!screen_source_is( + &token, + &source, + &zoom, + &frozen, + (101, 50) + )); + + frozen.set_active_output(None, Some(12)); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + assert!(screen_source_is(&token, &source, &zoom, &frozen, (100, 50))); + frozen.set_active_output(None, Some(11)); + + frozen.set_active_geometry(OutputGeometry::update_from( + Some((0, 0)), + Some((100, 50)), + (100, 50), + 2, + wl_output::Transform::Flipped, + Some((100, 50)), + )); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + assert!(screen_source_is(&token, &source, &zoom, &frozen, (100, 50))); + + zoom.active = true; + zoom.set_active_output(None, Some(12)); + zoom.set_active_geometry(OutputGeometry::update_from( + Some((0, 0)), + Some((100, 50)), + (100, 50), + 1, + wl_output::Transform::Normal, + Some((100, 50)), + )); + zoom.set_image_with_provenance_for_test( + opaque(100, 50), + provenance(12, 1, 1, wl_output::Transform::Normal), + ); + let zoom_token = { + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + screen_source_token(&source, &zoom, &frozen, (100, 50)) + }; + zoom.set_active_output(None, Some(99)); + zoom.set_active_geometry(OutputGeometry::update_from( + Some((0, 0)), + Some((100, 50)), + (100, 50), + 2, + wl_output::Transform::Flipped, + Some((100, 50)), + )); + { + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + assert!(screen_source_is( + &zoom_token, + &source, + &zoom, + &frozen, + (100, 50) + )); + } + zoom.view_offset = (1.0, 0.0); + { + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + assert!(!screen_source_is( + &zoom_token, + &source, + &zoom, + &frozen, + (100, 50) + )); + } + + zoom.view_offset = (0.0, 0.0); + zoom.set_image_with_provenance_for_test( + opaque(100, 50), + provenance(12, 1, 1, wl_output::Transform::Normal), + ); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + assert!(!screen_source_is( + &zoom_token, + &source, + &zoom, + &frozen, + (100, 50) + )); + } + + #[test] + fn displayed_source_refuses_images_without_complete_capture_identity() { + let zoom = zoom_state(); + let mut frozen = FrozenState::new(None); + frozen.set_image(opaque(2, 2)); + + assert!(displayed_screen_image(&zoom, &frozen, true).is_none()); + } + + #[test] + fn unzoomed_tokens_canonicalize_dormant_zoom_state() { + let mut frozen = FrozenState::new(None); + install_frozen(&mut frozen, opaque(100, 50)); + let mut zoom = zoom_state(); + zoom.scale = 7.0; + zoom.view_offset = (123.0, 456.0); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + + let token = screen_source_token(&source, &zoom, &frozen, (100, 50)); + + assert!(!token.zoom_transformed); + assert_eq!(token.zoom_scale, 1.0); + assert_eq!(token.zoom_view_offset, (0.0, 0.0)); + } + + #[test] + fn reversed_drags_normalize_and_partly_offscreen_drags_clip() { + let rect = image_rect_from_points((30.0, 40.0), (10.0, 20.0), 100, 100).unwrap(); + assert_eq!((rect.x(), rect.y(), rect.size()), (10, 20, (20, 20))); + + let clipped = image_rect_from_points((-50.0, -50.0), (10.5, 10.5), 100, 100).unwrap(); + assert_eq!((clipped.x(), clipped.y(), clipped.size()), (0, 0, (11, 11))); } #[test] @@ -374,16 +729,8 @@ mod tests { ], ); - let crop = copy_image_rect( - &source, - ImageRect { - x: 1, - y: 0, - width: 1, - height: 2, - }, - ) - .unwrap(); + let crop = + copy_image_rect(&source, ImagePixelRect::new(1, 0, 1, 2, (2, 2)).unwrap()).unwrap(); assert_eq!((crop.width, crop.height, crop.stride), (1, 2, 4)); assert_eq!(crop.data, vec![2, 2, 2, 255, 4, 4, 4, 255]); @@ -393,51 +740,27 @@ mod tests { fn crop_preserves_premultiplied_and_transparent_pixels_verbatim() { let source = image(2, 1, 8, vec![25, 50, 100, 128, 0, 0, 0, 0]); - let crop = copy_image_rect( - &source, - ImageRect { - x: 0, - y: 0, - width: 2, - height: 1, - }, - ) - .unwrap(); + let crop = + copy_image_rect(&source, ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap()).unwrap(); assert_eq!(crop.data, vec![25, 50, 100, 128, 0, 0, 0, 0]); } #[test] - fn crop_rejects_empty_and_out_of_bounds_rectangles() { + fn crop_rejects_rectangles_that_do_not_fit_the_actual_source() { let source = opaque(4, 4); let error = |rect| copy_image_rect(&source, rect).err(); assert_eq!( - error(ImageRect { - x: 0, - y: 0, - width: 0, - height: 2, - }), - Some(CropError::Empty) - ); - assert_eq!( - error(ImageRect { - x: 3, - y: 0, - width: 2, - height: 1, - }), + error(ImagePixelRect::new(3, 0, 2, 1, (5, 4)).unwrap()), Some(CropError::OutOfBounds) ); assert_eq!( - error(ImageRect { - x: u32::MAX, - y: 0, - width: 2, - height: 1, - }), - Some(CropError::OutOfBounds) + copy_image_rect( + &image(0, 0, 0, Vec::new()), + ImagePixelRect::new(0, 0, 1, 1, (1, 1)).unwrap(), + ), + Err(CropError::Empty) ); } @@ -456,16 +779,8 @@ mod tests { .expect("valid transform"); assert_eq!((rotated.width, rotated.height), (2, 3)); - let crop = copy_image_rect( - &rotated, - ImageRect { - x: 0, - y: 0, - width: 2, - height: 1, - }, - ) - .unwrap(); + let crop = + copy_image_rect(&rotated, ImagePixelRect::new(0, 0, 2, 1, (2, 3)).unwrap()).unwrap(); assert_eq!( crop.data.chunks_exact(4).map(|p| p[0]).collect::>(), diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index 0464d3ae5..511595dbd 100644 --- a/src/backend/wayland/zoom/capture.rs +++ b/src/backend/wayland/zoom/capture.rs @@ -11,7 +11,9 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; use crate::backend::wayland::capture::CaptureLayoutContext; -use crate::backend::wayland::frozen::{FrozenImage, copy_shm_argb, validate_shm_buffer_layout}; +use crate::backend::wayland::frozen::{ + FrozenImage, ScreenImageProvenance, copy_shm_argb, validate_shm_buffer_layout, +}; use crate::backend::wayland::frozen_geometry::{OutputGeometry, require_verified_capture_source}; use crate::input::InputState; @@ -73,6 +75,7 @@ impl CaptureSession { struct CaptureContext { layout: CaptureLayoutContext, + layout_generation: u64, source_geometry: OutputGeometry, } @@ -80,9 +83,20 @@ impl CaptureContext { fn new(target_output_id: u32, source_geometry: OutputGeometry, layout_generation: u64) -> Self { Self { layout: CaptureLayoutContext::new(target_output_id, layout_generation), + layout_generation, source_geometry, } } + + fn provenance(&self) -> ScreenImageProvenance { + ScreenImageProvenance::new( + self.layout.target_output_id(), + self.layout_generation, + self.source_geometry.scale, + self.source_geometry.transform, + ) + .expect("verified output geometry has a positive scale") + } } fn finalize_capture_image(image: FrozenImage, context: &CaptureContext) -> Result { @@ -108,11 +122,16 @@ impl ZoomState { use_fallback: bool, _tokio_handle: &tokio::runtime::Handle, ) -> Result<()> { + anyhow::ensure!( + self.source_terminal.is_none(), + "a zoom capture terminal is still pending" + ); if self.capture.is_some() || self.portal_in_progress || self.preflight_pending { warn!("Zoom capture already in progress; ignoring request"); return Ok(()); } + self.begin_identified_capture(); self.capture_done = false; self.preflight_use_fallback = use_fallback || self.manager.is_none(); self.snapshot_preflight_layout(); @@ -198,7 +217,7 @@ impl ZoomState { ); if let Err(err) = self.on_buffer(format, width, height, stride) { warn!("Failed to prepare zoom buffer: {}", err); - self.cancel(input_state, false); + self.fail_capture(input_state, false, err.to_string()); } } FrameEvent::LinuxDmabuf { .. } => { @@ -207,7 +226,7 @@ impl ZoomState { FrameEvent::BufferDone => { if let Err(err) = self.on_buffer_done() { warn!("Failed to issue zoom copy: {}", err); - self.cancel(input_state, false); + self.fail_capture(input_state, false, err.to_string()); } } FrameEvent::Flags { flags } => { @@ -231,7 +250,7 @@ impl ZoomState { } Err(err) => { warn!("Zoom capture ready handling failed: {}", err); - self.cancel(input_state, false); + self.fail_capture(input_state, false, err.to_string()); return; } } @@ -243,6 +262,9 @@ impl ZoomState { input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; + self.finish_source_capture(super::state::ZoomSourceOutcome::Ready { + installed_generation: self.image_generation(), + }); self.capture_done = true; if let Some(image) = self.image() { info!( @@ -256,7 +278,7 @@ impl ZoomState { } FrameEvent::Failed => { warn!("Zoom capture failed"); - self.cancel(input_state, false); + self.fail_capture(input_state, false, "Zoom capture failed"); } _ => {} } @@ -338,7 +360,8 @@ impl ZoomState { return Ok(false); } - self.set_image(finalize_capture_image(image, &capture.context)?); + let provenance = capture.context.provenance(); + self.install_image(finalize_capture_image(image, &capture.context)?, provenance); Ok(true) } @@ -349,6 +372,25 @@ mod tests { use super::*; use wayland_client::protocol::wl_output; + #[test] + fn capture_context_retains_complete_source_provenance() { + let geometry = OutputGeometry::update_from( + Some((0, 0)), + Some((3, 2)), + (3, 2), + 2, + wl_output::Transform::_90, + Some((5, 3)), + ) + .expect("verified source geometry"); + let context = CaptureContext::new(7, geometry, 3); + + assert_eq!( + context.provenance(), + ScreenImageProvenance::new(7, 3, 2, wl_output::Transform::_90).unwrap() + ); + } + #[test] fn finalized_capture_rejects_known_pixel_size_mismatch() { let geometry = OutputGeometry::update_from( diff --git a/src/backend/wayland/zoom/mod.rs b/src/backend/wayland/zoom/mod.rs index 9249afb29..ef202e5e7 100644 --- a/src/backend/wayland/zoom/mod.rs +++ b/src/backend/wayland/zoom/mod.rs @@ -4,6 +4,11 @@ mod state; mod view; pub use state::ZoomState; +#[allow(unused_imports)] +pub(in crate::backend::wayland) use state::{ + ZoomCaptureId, ZoomSourceOutcome, ZoomSourceTerminal, ZoomTerminalReport, ZoomWaiter, + ZoomWaiterOwner, ZoomWaiterRegistry, +}; const MIN_ZOOM_SCALE: f64 = 1.0; const MAX_ZOOM_SCALE: f64 = 8.0; @@ -12,6 +17,7 @@ type PortalCaptureResult = Result< ( Option, u64, + crate::backend::wayland::frozen::ScreenImageProvenance, crate::backend::wayland::frozen::FrozenImage, ), crate::capture::types::CaptureError, diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index a483ded29..ec3cfbbfd 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -2,7 +2,7 @@ use anyhow::Result; use log::warn; use std::time::{Duration, Instant}; -use crate::backend::wayland::frozen::FrozenImage; +use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; use crate::backend::wayland::frozen_geometry::{OutputGeometry, require_verified_capture_source}; use crate::backend::wayland::portal_capture::{ capture_via_portal_fullscreen_bytes, crop_argb, portal_output_matches, @@ -12,7 +12,7 @@ use crate::capture::sources::frozen::decode_image_to_argb; use crate::capture::types::CaptureError; use crate::input::InputState; -use super::state::ZoomState; +use super::state::{ZoomSourceOutcome, ZoomState}; impl ZoomState { pub(super) fn capture_via_portal( @@ -38,6 +38,13 @@ impl ZoomState { self.portal_target_output_id = Some(target_output_id); let layout_generation = self.output_layout_generation; + let provenance = ScreenImageProvenance::new( + target_output_id, + layout_generation, + geo.scale, + geo.transform, + ) + .expect("verified output geometry has a positive scale"); crate::notification::send_notification_async( tokio_handle, "Zoom capture".to_string(), @@ -52,7 +59,7 @@ impl ZoomState { .map_err(|error| CaptureError::ImageError(format!("Decode failed: {error}")))?; let image = crop_portal_image(data, width, height, &geo)?; - Ok((Some(target_output_id), layout_generation, image)) + Ok((Some(target_output_id), layout_generation, provenance, image)) } .await })); @@ -76,14 +83,10 @@ impl ZoomState { .is_some_and(|task| task.timed_out(now)) { warn!("Portal zoom capture timed out; restoring overlay"); - self.finish_portal_task(); - if self.image.is_none() { - self.active = false; - } - self.pending_activation = false; - input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); - input_state.needs_redraw = true; - self.capture_done = true; + self.finish_failed_portal_task( + input_state, + ZoomSourceOutcome::Failed("Portal zoom capture timed out".to_string()), + ); return; } @@ -93,7 +96,7 @@ impl ZoomState { .map(PortalTask::poll) .unwrap_or(PortalPoll::Disconnected); match poll { - PortalPoll::Ready(Ok((target_output, layout_generation, image))) => { + PortalPoll::Ready(Ok((target_output, layout_generation, provenance, image))) => { let output_matches = portal_output_matches(target_output, self.active_output_id); let layout_matches = layout_generation == self.output_layout_generation; @@ -111,19 +114,17 @@ impl ZoomState { geometry.output_count_conflicts_with_live(live_output_count) }) { warn!("Portal zoom capture discarded after output topology changed"); - Self::push_stale_layout_toast(input_state); - self.finish_failed_portal_task(input_state); + self.finish_failed_portal_task(input_state, ZoomSourceOutcome::StaleLayout); return; } - self.set_image(image); + self.install_image(image, provenance); } else { if !layout_matches { warn!("Portal zoom capture discarded after the output layout changed"); } else { warn!("Portal zoom capture for inactive output discarded"); } - Self::push_stale_layout_toast(input_state); - self.finish_failed_portal_task(input_state); + self.finish_failed_portal_task(input_state, ZoomSourceOutcome::StaleLayout); return; } @@ -139,24 +140,35 @@ impl ZoomState { input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; + self.finish_source_capture(ZoomSourceOutcome::Ready { + installed_generation: self.image_generation(), + }); self.capture_done = true; } PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { log::info!("Portal zoom capture cancelled: {reason}"); - self.finish_failed_portal_task(input_state); + self.finish_failed_portal_task(input_state, ZoomSourceOutcome::Cancelled); } PortalPoll::Ready(Err(err)) => { warn!("Portal zoom capture failed: {err}"); - self.finish_failed_portal_task(input_state); + self.finish_failed_portal_task( + input_state, + ZoomSourceOutcome::Failed(err.to_string()), + ); } PortalPoll::Failed(err) => { warn!("Portal zoom capture task failed: {err}"); - self.finish_failed_portal_task(input_state); + self.finish_failed_portal_task(input_state, ZoomSourceOutcome::Failed(err)); } PortalPoll::Pending => {} PortalPoll::Disconnected => { warn!("Portal zoom capture channel disconnected"); - self.finish_failed_portal_task(input_state); + self.finish_failed_portal_task( + input_state, + ZoomSourceOutcome::Failed( + "Portal zoom capture channel disconnected".to_string(), + ), + ); } } } @@ -171,12 +183,17 @@ impl ZoomState { self.portal_target_output_id = None; } - fn finish_failed_portal_task(&mut self, input_state: &mut InputState) { + fn finish_failed_portal_task( + &mut self, + input_state: &mut InputState, + outcome: ZoomSourceOutcome, + ) { self.finish_portal_task(); if self.image.is_none() { self.active = false; } self.pending_activation = false; + self.finish_source_capture(outcome); input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); input_state.needs_redraw = true; self.capture_done = true; @@ -251,6 +268,23 @@ mod tests { } } + fn provenance(output_id: u32, layout_generation: u64) -> ScreenImageProvenance { + ScreenImageProvenance::new( + output_id, + layout_generation, + 1, + wayland_client::protocol::wl_output::Transform::Normal, + ) + .unwrap() + } + + fn stale_report() -> Option { + Some(super::super::state::ZoomTerminalReport { + source: "zoom", + message: "Zoom failed after the display layout changed".to_string(), + }) + } + fn crop_geometry( origin: (u32, u32), ) -> crate::backend::wayland::frozen_geometry::OutputGeometry { @@ -366,11 +400,13 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); 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.set_active_output(None, Some(1)); zoom.request_activation(); zoom.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((None, 0, image(3))) }, + async { Ok((Some(1), 0, provenance(1, 0), image(3))) }, )); zoom.portal_in_progress = true; @@ -379,7 +415,18 @@ mod tests { assert!(zoom.active); assert!(!zoom.pending_activation); assert_eq!(zoom.image().unwrap().data, vec![3; 8]); + assert_eq!(zoom.image_provenance(), Some(provenance(1, 0))); assert!(zoom.take_capture_done()); + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::Ready { + installed_generation: zoom.image_generation(), + }, + report: None, + }) + ); } #[tokio::test] @@ -388,6 +435,7 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); 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.request_activation(); zoom.portal_task = Some(if panic_task { PortalTask::spawn(&tokio::runtime::Handle::current(), wake.handle(), async { @@ -407,9 +455,43 @@ mod tests { assert!(!zoom.pending_activation); assert!(zoom.portal_task.is_none()); assert!(zoom.take_capture_done()); + assert!(matches!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id: terminal_id, + outcome: ZoomSourceOutcome::Failed(_), + .. + }) if terminal_id == id + )); } } + #[tokio::test] + async fn portal_dismissal_emits_a_correlated_cancelled_terminal() { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + 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.portal_task = Some(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async { Err(CaptureError::Cancelled("dismissed".to_string())) }, + )); + zoom.portal_in_progress = true; + + poll_until_finished(&mut zoom, &mut input).await; + + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::Cancelled, + report: None, + }) + ); + assert_eq!(zoom.current_capture_id(), None); + } + #[tokio::test] async fn disconnect_and_deadline_expiry_restore_without_a_producer_result() { let now = Instant::now(); @@ -417,6 +499,7 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); 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.request_activation(); zoom.portal_task = Some(if timed_out { PortalTask::spawn_at_for_test( @@ -437,6 +520,14 @@ mod tests { assert!(!zoom.pending_activation); assert!(zoom.portal_task.is_none()); assert!(zoom.take_capture_done()); + assert!(matches!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id: terminal_id, + outcome: ZoomSourceOutcome::Failed(_), + .. + }) if terminal_id == id + )); } } @@ -445,6 +536,7 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); 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.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_output(None, Some(2)); @@ -452,7 +544,7 @@ mod tests { zoom.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((Some(1), 0, image(9))) }, + async { Ok((Some(1), 0, provenance(1, 0), image(9))) }, )); zoom.portal_in_progress = true; @@ -463,7 +555,15 @@ mod tests { assert_eq!(zoom.image_generation(), generation); assert_eq!(zoom.image().unwrap().data, vec![4; 8]); assert!(zoom.take_capture_done()); - assert!(input.ui_toast.is_some()); + assert_eq!(input.test_toast_count(), 0); + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::StaleLayout, + report: stale_report(), + }) + ); } #[tokio::test] @@ -471,15 +571,24 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); 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.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_geometry(Some(crop_geometry((0, 0)))); + zoom.set_active_output(None, Some(1)); let layout_generation = zoom.output_layout_generation; zoom.request_activation(); zoom.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async move { Ok((None, layout_generation, image(9))) }, + async move { + Ok(( + Some(1), + layout_generation, + provenance(1, layout_generation), + image(9), + )) + }, )); zoom.portal_in_progress = true; zoom.set_active_geometry(Some(crop_geometry((6, 0)))); @@ -491,7 +600,15 @@ mod tests { assert_eq!(zoom.image_generation(), generation); assert_eq!(zoom.image().unwrap().data, vec![4; 8]); assert!(zoom.take_capture_done()); - assert!(input.ui_toast.is_some()); + assert_eq!(input.test_toast_count(), 0); + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::StaleLayout, + report: stale_report(), + }) + ); } #[tokio::test] @@ -499,14 +616,23 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); + let id = zoom.begin_identified_capture(); let geometry = crop_geometry((0, 0)); zoom.set_active_geometry(Some(geometry.clone())); + zoom.set_active_output(None, Some(1)); let layout_generation = zoom.output_layout_generation; zoom.request_activation(); zoom.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async move { Ok((None, layout_generation, image(3))) }, + async move { + Ok(( + Some(1), + layout_generation, + provenance(1, layout_generation), + image(3), + )) + }, )); zoom.portal_in_progress = true; zoom.set_active_geometry(Some(geometry)); @@ -517,6 +643,16 @@ mod tests { assert!(!zoom.pending_activation); assert_eq!(zoom.image().unwrap().data, vec![3; 8]); assert!(zoom.take_capture_done()); + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::Ready { + installed_generation: zoom.image_generation(), + }, + report: None, + }) + ); } #[tokio::test] @@ -524,15 +660,24 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); 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.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_geometry(Some(crop_geometry((0, 0)).with_known_output_count(Some(1)))); + zoom.set_active_output(None, Some(1)); let layout_generation = zoom.output_layout_generation; zoom.request_activation(); zoom.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async move { Ok((None, layout_generation, image(9))) }, + async move { + Ok(( + Some(1), + layout_generation, + provenance(1, layout_generation), + image(9), + )) + }, )); zoom.portal_in_progress = true; @@ -543,13 +688,22 @@ mod tests { assert_eq!(zoom.image_generation(), generation); assert_eq!(zoom.image().unwrap().data, vec![4; 8]); assert!(zoom.take_capture_done()); - assert!(input.ui_toast.is_some()); + assert_eq!(input.test_toast_count(), 0); + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::StaleLayout, + report: stale_report(), + }) + ); } #[tokio::test] async fn supersession_is_ignored_and_explicit_abort_owns_task_cancellation() { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); + let id = zoom.begin_identified_capture(); zoom.request_activation(); zoom.portal_task = Some(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -564,5 +718,13 @@ mod tests { assert!(zoom.abort_capture()); assert!(zoom.portal_task.is_none()); assert!(!zoom.portal_in_progress); + assert_eq!( + zoom.take_source_terminal(), + Some(super::super::state::ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::Aborted, + report: None, + }) + ); } } diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index 60b0138b1..c40640c64 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -2,16 +2,103 @@ use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; use crate::backend::wayland::RuntimeWakeHandle; -use crate::backend::wayland::frozen::FrozenImage; +use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; use crate::backend::wayland::frozen_geometry::OutputGeometry; use crate::backend::wayland::portal_capture::layout_token_matches; use crate::backend::wayland::portal_task::PortalTask; use crate::input::InputState; -use crate::input::state::{Toast, ToastPriority}; use super::capture::CaptureSession; use super::{MIN_ZOOM_SCALE, PortalCaptureResult}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(in crate::backend::wayland) struct ZoomCaptureId(u64); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum ZoomSourceOutcome { + Ready { installed_generation: u64 }, + Aborted, + Cancelled, + Deactivated, + StaleLayout, + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ZoomSourceTerminal { + pub id: ZoomCaptureId, + pub outcome: ZoomSourceOutcome, + pub report: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ZoomTerminalReport { + pub source: &'static str, + pub message: String, +} + +#[cfg(test)] +impl ZoomSourceTerminal { + pub fn for_test(outcome: ZoomSourceOutcome, report: Option) -> Self { + Self { + id: ZoomCaptureId(1), + outcome, + report, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(in crate::backend::wayland) enum ZoomWaiterOwner { + Eyedropper, + Ocr, + #[allow(dead_code)] // Phase 1 connects the native region-capture owner. + RegionCapture, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ZoomWaiter { + pub id: ZoomCaptureId, + pub owner: ZoomWaiterOwner, +} + +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct ZoomWaiterRegistry { + waiter: Option, +} + +impl ZoomWaiterRegistry { + pub fn register(&mut self, waiter: ZoomWaiter) -> bool { + if self.waiter.is_some() { + return false; + } + self.waiter = Some(waiter); + true + } + + #[cfg(test)] + pub fn waiter(&self) -> Option { + self.waiter + } + + pub fn take_for_terminal( + &mut self, + terminal: &ZoomSourceTerminal, + ) -> Option<(ZoomWaiter, bool)> { + let waiter = self.waiter.take()?; + let matches = waiter.id == terminal.id; + Some((waiter, matches)) + } + + pub fn clear_owner(&mut self, owner: ZoomWaiterOwner) -> bool { + if !self.waiter.is_some_and(|waiter| waiter.owner == owner) { + return false; + } + self.waiter.take(); + true + } +} + /// Zoom state, capture logic, and pan/lock bookkeeping. pub struct ZoomState { pub(super) manager: Option, @@ -23,6 +110,7 @@ pub struct ZoomState { pub(super) output_layout_generation: u64, pub(super) capture: Option, pub(super) image: Option, + image_provenance: Option, pub(super) image_target_dimensions: Option<(u32, u32)>, image_generation: u64, pub(super) portal_task: Option>, @@ -34,6 +122,9 @@ pub struct ZoomState { preflight_output_id: Option, preflight_layout_generation: Option, pub(super) capture_done: bool, + next_capture_id: u64, + current_capture_id: Option, + pub(super) source_terminal: Option, pub(super) pending_activation: bool, pub active: bool, pub locked: bool, @@ -68,6 +159,7 @@ impl ZoomState { output_layout_generation: 0, capture: None, image: None, + image_provenance: None, image_target_dimensions: None, image_generation: 0, portal_task: None, @@ -79,6 +171,9 @@ impl ZoomState { preflight_output_id: None, preflight_layout_generation: None, capture_done: false, + next_capture_id: 1, + current_capture_id: None, + source_terminal: None, pending_activation: false, active: false, locked: false, @@ -105,6 +200,11 @@ impl ZoomState { self.active_geometry = geometry; } + pub(in crate::backend::wayland) fn image_provenance(&self) -> Option { + self.image.as_ref()?; + self.image_provenance + } + pub fn image(&self) -> Option<&FrozenImage> { self.image.as_ref() } @@ -113,6 +213,22 @@ impl ZoomState { self.image_generation } + pub(in crate::backend::wayland) fn install_image( + &mut self, + image: FrozenImage, + provenance: ScreenImageProvenance, + ) { + self.image_target_dimensions = self + .active_geometry + .as_ref() + .map(OutputGeometry::buffer_size) + .or(Some((image.width, image.height))); + self.image = Some(image); + self.image_provenance = Some(provenance); + self.bump_image_generation(); + } + + #[cfg(test)] pub fn set_image(&mut self, image: FrozenImage) { self.image_target_dimensions = self .active_geometry @@ -120,11 +236,29 @@ impl ZoomState { .map(OutputGeometry::buffer_size) .or(Some((image.width, image.height))); self.image = Some(image); + self.image_provenance = None; + self.bump_image_generation(); + } + + #[cfg(test)] + pub(in crate::backend::wayland) fn set_image_with_provenance_for_test( + &mut self, + image: FrozenImage, + provenance: ScreenImageProvenance, + ) { + self.image_target_dimensions = self + .active_geometry + .as_ref() + .map(OutputGeometry::buffer_size) + .or(Some((image.width, image.height))); + self.image = Some(image); + self.image_provenance = Some(provenance); self.bump_image_generation(); } pub fn clear_image(&mut self) -> bool { let had_image = self.image.take().is_some(); + self.image_provenance = None; self.image_target_dimensions = None; if had_image { self.bump_image_generation(); @@ -177,17 +311,8 @@ impl ZoomState { self.ensure_preflight_layout_current().is_ok() } - pub(super) fn push_stale_layout_toast(input_state: &mut InputState) { - input_state.push_toast( - ToastPriority::Critical, - "zoom", - Toast::error("Zoom failed after the display layout changed"), - ); - } - pub(super) fn finish_stale_direct_capture(&mut self, input_state: &mut InputState) { - Self::push_stale_layout_toast(input_state); - self.cancel(input_state, false); + self.cancel_with_outcome(input_state, false, ZoomSourceOutcome::StaleLayout); } fn clear_preflight_layout_snapshot(&mut self) { @@ -201,6 +326,54 @@ impl ZoomState { done } + pub(in crate::backend::wayland) fn current_capture_id(&self) -> Option { + self.current_capture_id + } + + pub(in crate::backend::wayland) fn take_source_terminal( + &mut self, + ) -> Option { + self.source_terminal.take() + } + + pub(super) fn begin_identified_capture(&mut self) -> ZoomCaptureId { + let id = ZoomCaptureId(self.next_capture_id); + self.next_capture_id = self + .next_capture_id + .checked_add(1) + .expect("zoom capture id space exhausted"); + self.current_capture_id = Some(id); + id + } + + pub(super) fn finish_source_capture(&mut self, outcome: ZoomSourceOutcome) { + self.finish_source_capture_with_report(outcome, None); + } + + fn finish_source_capture_with_report( + &mut self, + outcome: ZoomSourceOutcome, + report: Option, + ) { + let Some(id) = self.current_capture_id.take() else { + return; + }; + let report = report.or_else(|| { + matches!(outcome, ZoomSourceOutcome::StaleLayout).then(|| ZoomTerminalReport { + source: "zoom", + message: "Zoom failed after the display layout changed".to_string(), + }) + }); + debug_assert!(self.source_terminal.is_none()); + if self.source_terminal.is_none() { + self.source_terminal = Some(ZoomSourceTerminal { + id, + outcome, + report, + }); + } + } + pub fn is_engaged(&self) -> bool { self.active || self.pending_activation } @@ -235,13 +408,14 @@ impl ZoomState { self.portal_target_output_id = None; self.pending_activation = false; if changed { + self.finish_source_capture(ZoomSourceOutcome::Aborted); self.capture_done = true; } changed } pub fn deactivate(&mut self, input_state: &mut InputState) { - self.cancel(input_state, true); + self.cancel_with_outcome(input_state, true, ZoomSourceOutcome::Deactivated); } pub fn reset_view(&mut self) { @@ -251,7 +425,57 @@ impl ZoomState { self.last_pan_pos = (0.0, 0.0); } + #[allow(dead_code)] // Kept as the explicit non-deactivation capture terminal. pub fn cancel(&mut self, input_state: &mut InputState, force_reset: bool) { + self.cancel_with_outcome(input_state, force_reset, ZoomSourceOutcome::Cancelled); + } + + pub(in crate::backend::wayland) fn fail_capture( + &mut self, + input_state: &mut InputState, + force_reset: bool, + message: impl Into, + ) { + self.cancel_with_outcome( + input_state, + force_reset, + ZoomSourceOutcome::Failed(message.into()), + ); + } + + pub(in crate::backend::wayland) fn finish_preflight_failure( + &mut self, + input_state: &mut InputState, + message: String, + ) { + let report = ZoomTerminalReport { + source: "zoom", + message: message.clone(), + }; + let outcome = if message == "Zoom failed after the display layout changed" { + ZoomSourceOutcome::StaleLayout + } else { + ZoomSourceOutcome::Failed(message) + }; + self.cancel_with_outcome_and_report(input_state, false, outcome, Some(report)); + } + + pub(super) fn cancel_with_outcome( + &mut self, + input_state: &mut InputState, + force_reset: bool, + outcome: ZoomSourceOutcome, + ) { + self.cancel_with_outcome_and_report(input_state, force_reset, outcome, None); + } + + fn cancel_with_outcome_and_report( + &mut self, + input_state: &mut InputState, + force_reset: bool, + outcome: ZoomSourceOutcome, + report: Option, + ) { if let Some(capture) = self.capture.take() { capture.frame.destroy(); } @@ -265,6 +489,7 @@ impl ZoomState { } self.portal_target_output_id = None; self.pending_activation = false; + self.finish_source_capture_with_report(outcome, report); if force_reset || self.image.is_none() { self.active = false; @@ -288,6 +513,170 @@ mod tests { use super::*; use crate::input::state::test_support::make_test_input_state; + #[tokio::test] + async fn capture_ids_are_monotonic_and_terminals_require_a_current_capture() { + let mut state = ZoomState::new(None); + + assert_eq!(state.current_capture_id(), None); + state.abort_capture(); + assert_eq!(state.take_source_terminal(), None); + + state + .start_capture(false, &tokio::runtime::Handle::current()) + .expect("first capture starts"); + let first = state.current_capture_id().expect("first capture id"); + assert!(state.abort_capture()); + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id: first, + outcome: ZoomSourceOutcome::Aborted, + report: None, + }) + ); + + state + .start_capture(false, &tokio::runtime::Handle::current()) + .expect("second capture starts"); + let second = state.current_capture_id().expect("second capture id"); + assert!(second > first); + } + + #[tokio::test] + async fn capture_refuses_to_start_until_the_previous_terminal_is_drained() { + let mut state = ZoomState::new(None); + + state + .start_capture(false, &tokio::runtime::Handle::current()) + .expect("first capture starts"); + let first = state.current_capture_id().expect("first capture id"); + assert!(state.abort_capture()); + + let error = state + .start_capture(false, &tokio::runtime::Handle::current()) + .expect_err("an undrained terminal blocks the next capture"); + + assert_eq!( + error.to_string(), + "a zoom capture terminal is still pending" + ); + assert_eq!(state.current_capture_id(), None); + assert!(!state.preflight_pending()); + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id: first, + outcome: ZoomSourceOutcome::Aborted, + report: None, + }) + ); + } + + #[test] + fn mismatched_terminal_takes_the_waiter_for_fail_closed_owner_cancellation() { + let stale_id = ZoomCaptureId(3); + let newer = ZoomWaiter { + id: ZoomCaptureId(4), + owner: ZoomWaiterOwner::Ocr, + }; + let mut registry = ZoomWaiterRegistry::default(); + assert!(registry.register(newer)); + + let terminal = ZoomSourceTerminal { + id: stale_id, + outcome: ZoomSourceOutcome::Failed("old failure".to_string()), + report: None, + }; + + assert_eq!(registry.take_for_terminal(&terminal), Some((newer, false))); + assert_eq!(registry.waiter(), None); + } + + #[test] + fn typed_terminal_is_emitted_once_for_each_current_capture() { + let outcomes = [ + ZoomSourceOutcome::Ready { + installed_generation: 9, + }, + ZoomSourceOutcome::Aborted, + ZoomSourceOutcome::Cancelled, + ZoomSourceOutcome::Deactivated, + ZoomSourceOutcome::StaleLayout, + ZoomSourceOutcome::Failed("failed".to_string()), + ]; + + for outcome in outcomes { + let mut state = ZoomState::new(None); + let id = state.begin_identified_capture(); + state.finish_source_capture(outcome.clone()); + state.finish_source_capture(ZoomSourceOutcome::Failed("duplicate".to_string())); + let report = + matches!(&outcome, ZoomSourceOutcome::StaleLayout).then(|| ZoomTerminalReport { + source: "zoom", + message: "Zoom failed after the display layout changed".to_string(), + }); + + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id, + outcome, + report, + }) + ); + assert_eq!(state.current_capture_id(), None); + assert_eq!(state.take_source_terminal(), None); + } + } + + #[test] + fn cancel_and_deactivate_publish_distinct_terminals() { + let mut state = ZoomState::new(None); + let mut input_state = make_test_input_state(); + let cancelled = state.begin_identified_capture(); + state.cancel(&mut input_state, false); + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id: cancelled, + outcome: ZoomSourceOutcome::Cancelled, + report: None, + }) + ); + + let deactivated = state.begin_identified_capture(); + state.deactivate(&mut input_state); + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id: deactivated, + outcome: ZoomSourceOutcome::Deactivated, + report: None, + }) + ); + } + + #[test] + fn preflight_failure_terminal_carries_the_specific_error_report() { + let mut state = ZoomState::new(None); + let mut input_state = make_test_input_state(); + let id = state.begin_identified_capture(); + + state.finish_preflight_failure(&mut input_state, "specific backend failure".to_string()); + + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::Failed("specific backend failure".to_string()), + report: Some(ZoomTerminalReport { + source: "zoom", + message: "specific backend failure".to_string(), + }), + }) + ); + } + #[test] fn aborting_pending_activation_completes_the_capture_lifecycle() { let mut state = ZoomState::new(None); @@ -302,9 +691,10 @@ mod tests { } #[test] - fn stale_direct_capture_toasts_and_preserves_the_current_image() { + fn stale_direct_capture_publishes_one_report_and_preserves_the_current_image() { let mut state = ZoomState::new(None); let mut input_state = make_test_input_state(); + let id = state.begin_identified_capture(); state.set_image(FrozenImage { width: 1, height: 1, @@ -315,11 +705,18 @@ mod tests { state.finish_stale_direct_capture(&mut input_state); - let toast = input_state - .ui_toast - .as_ref() - .expect("visible stale rejection"); - assert!(toast.message.contains("display layout changed")); + assert_eq!(input_state.test_toast_count(), 0); + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::StaleLayout, + report: Some(ZoomTerminalReport { + source: "zoom", + message: "Zoom failed after the display layout changed".to_string(), + }), + }) + ); assert_eq!(state.image_generation(), generation); assert_eq!(state.image().unwrap().data, vec![4; 4]); assert!(state.take_capture_done()); diff --git a/src/capture/mod.rs b/src/capture/mod.rs index b21657c65..704784dd0 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -9,6 +9,7 @@ pub mod clipboard; pub mod file; +pub(crate) mod png; #[cfg(feature = "portal")] pub mod portal; pub mod sources; diff --git a/src/capture/png.rs b/src/capture/png.rs new file mode 100644 index 000000000..055499083 --- /dev/null +++ b/src/capture/png.rs @@ -0,0 +1,83 @@ +use crate::capture::{CaptureError, ImageFormatMetadata, RenderedImage}; +use crate::screen_pixels::PackedArgb32; + +/// Encode tightly packed premultiplied native-order Cairo ARGB32 pixels as PNG. +pub fn encode_packed_argb32_png(pixels: &PackedArgb32) -> Result { + if pixels.width() == 0 || pixels.height() == 0 { + return Err(CaptureError::ImageError( + "Packed ARGB32 PNG encoding requires non-empty pixels".to_string(), + )); + } + let width = i32::try_from(pixels.width()) + .map_err(|_| CaptureError::ImageError("ARGB32 image width is too large".to_string()))?; + let height = i32::try_from(pixels.height()) + .map_err(|_| CaptureError::ImageError("ARGB32 image height is too large".to_string()))?; + let surface = cairo::ImageSurface::create_for_data( + pixels.data().to_vec(), + cairo::Format::ARgb32, + width, + height, + pixels.stride(), + ) + .map_err(|err| { + CaptureError::ImageError(format!("Failed to create packed ARGB32 surface: {err}")) + })?; + let mut bytes = Vec::new(); + surface.write_to_png(&mut bytes).map_err(|err| { + CaptureError::ImageError(format!("Failed to encode packed ARGB32 PNG: {err}")) + })?; + Ok(RenderedImage { + bytes, + format: ImageFormatMetadata::png(), + width: pixels.width(), + height: pixels.height(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn native_argb(pixels: &[u32]) -> Vec { + pixels + .iter() + .flat_map(|pixel| pixel.to_ne_bytes()) + .collect() + } + + #[test] + fn packed_argb32_png_preserves_alpha_and_premultiplied_channels() { + let source_data = native_argb(&[ + 0xFF00_00FF, // opaque blue + 0x0000_0000, // fully transparent + 0x8040_4040, // half-alpha premultiplied grey + ]); + let source = PackedArgb32::new(3, 1, 12, source_data.clone()).unwrap(); + + let rendered = encode_packed_argb32_png(&source).expect("crop encodes"); + assert_eq!((rendered.width, rendered.height), (3, 1)); + assert_eq!(rendered.format, ImageFormatMetadata::png()); + + let decoded = cairo::ImageSurface::create_from_png(&mut rendered.bytes.as_slice()) + .expect("encoded PNG decodes"); + let mut decoded = decoded; + let stride = decoded.stride() as usize; + let data = decoded.data().expect("decoded pixels"); + assert_eq!(&data[..12], &source_data); + assert!(stride >= 12); + } + + #[test] + fn packed_argb32_png_rejects_empty_dimensions_before_cairo() { + let zero_width = PackedArgb32::new(0, 4, 0, Vec::new()).unwrap(); + let zero_height = PackedArgb32::new(4, 0, 16, Vec::new()).unwrap(); + assert!(matches!( + encode_packed_argb32_png(&zero_width), + Err(CaptureError::ImageError(_)) + )); + assert!(matches!( + encode_packed_argb32_png(&zero_height), + Err(CaptureError::ImageError(_)) + )); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 31b52c700..b89a996b8 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -25,8 +25,9 @@ pub use state::{ BoardPickerCursorHint, ClickHighlightSettings, ColorPickerCursorHint, CommandPaletteCursorHint, ContextMenuCursorHint, DrawingState, EyedropperUiState, HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome, InputHudActiveSource, InputHudEntry, - InputHudEntryKind, InputHudSettings, InputState, OcrInputSource, OcrUiState, OutputFocusAction, - SelectionHandle, SelectionPropertyEntry, SelectionPropertyKind, TextInputMode, ZoomAction, + InputHudEntryKind, InputHudSettings, InputState, OutputFocusAction, RegionInputSource, + RegionPurposeTag, RegionSelectUiState, SelectionHandle, SelectionPropertyEntry, + SelectionPropertyKind, TextInputMode, ZoomAction, }; #[cfg(feature = "tablet-input")] #[allow(unused_imports)] diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index b85442241..a7f208aba 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -300,7 +300,7 @@ impl InputState { pending_frozen_toggle: false, eyedropper_ui_state: crate::input::state::core::EyedropperUiState::Inactive, pending_eyedropper_toggle: false, - ocr_ui_state: crate::input::state::core::OcrUiState::Inactive, + region_select_ui_state: crate::input::state::core::RegionSelectUiState::Inactive, pending_ocr_request: false, ocr_cancelled_by_toolbar: false, zoom_active: false, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index eabd37da7..20f631a9b 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -601,8 +601,9 @@ pub struct InputState { crate::input::state::core::EyedropperUiState, /// Pending eyedropper activation request for the Wayland backend. pub(in crate::input::state::core) pending_eyedropper_toggle: bool, - /// Screen text recognition (OCR) region-selector lifecycle. - pub(in crate::input::state::core) ocr_ui_state: crate::input::state::core::OcrUiState, + /// Generalized screen-region selector lifecycle. + pub(in crate::input::state::core) region_select_ui_state: + crate::input::state::core::RegionSelectUiState, /// Pending `Copy text from screen` request for the Wayland backend. pub(in crate::input::state::core) pending_ocr_request: bool, /// A toolbar interaction dismissed the selector in this input batch, so a diff --git a/src/input/state/core/eyedropper.rs b/src/input/state/core/eyedropper.rs index 788f74e58..47404cd38 100644 --- a/src/input/state/core/eyedropper.rs +++ b/src/input/state/core/eyedropper.rs @@ -1,7 +1,6 @@ use super::InputState; use crate::domain::Action; use crate::input::Key; -use crate::input::state::{Toast, ToastPriority}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EyedropperCaptureSource { @@ -16,11 +15,11 @@ pub enum EyedropperUiState { Inactive, PendingCapture { source: EyedropperCaptureSource, - auto_froze: bool, + owned_frozen_generation: Option, }, Active { hover: Option<(f64, f64)>, - auto_froze: bool, + owned_frozen_generation: Option, }, } @@ -51,10 +50,17 @@ impl EyedropperUiState { } } - fn auto_froze(self) -> bool { + pub(crate) fn owned_frozen_generation(self) -> Option { match self { - Self::PendingCapture { auto_froze, .. } | Self::Active { auto_froze, .. } => auto_froze, - Self::Inactive => false, + Self::PendingCapture { + owned_frozen_generation, + .. + } + | Self::Active { + owned_frozen_generation, + .. + } => owned_frozen_generation, + Self::Inactive => None, } } } @@ -110,20 +116,20 @@ impl InputState { pub(crate) fn set_eyedropper_pending_capture(&mut self, source: EyedropperCaptureSource) { self.eyedropper_ui_state = EyedropperUiState::PendingCapture { source, - auto_froze: matches!(source, EyedropperCaptureSource::Frozen), + owned_frozen_generation: None, }; self.dirty_tracker.mark_full(); self.needs_redraw = true; } - pub(crate) fn activate_eyedropper(&mut self, auto_froze: bool) { + pub(crate) fn activate_eyedropper(&mut self, owned_frozen_generation: Option) { // A capture can take long enough for another interaction to begin while // the eyedropper is pending. Entering the modal state must cancel it so // the eyedropper cannot swallow the matching release event. self.prepare_for_screen_modal(); self.eyedropper_ui_state = EyedropperUiState::Active { hover: None, - auto_froze, + owned_frozen_generation, }; self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -139,25 +145,15 @@ impl InputState { } } - /// Leave eyedropper mode and return whether it created frozen mode itself. - pub(crate) fn cancel_eyedropper(&mut self) -> bool { - let auto_froze = self.eyedropper_ui_state.auto_froze(); + /// Leave eyedropper mode and return the exact frozen generation it owns. + pub(crate) fn cancel_eyedropper(&mut self) -> Option { + let owned_frozen_generation = self.eyedropper_ui_state.owned_frozen_generation(); if !matches!(self.eyedropper_ui_state, EyedropperUiState::Inactive) { self.eyedropper_ui_state = EyedropperUiState::Inactive; self.dirty_tracker.mark_full(); self.needs_redraw = true; } - auto_froze - } - - pub(crate) fn report_eyedropper_capture_failure_if_unreported(&mut self) { - if self.ui_toast.is_none() { - self.push_toast( - ToastPriority::Critical, - "eyedropper", - Toast::error("Screen eyedropper capture failed."), - ); - } + owned_frozen_generation } } @@ -168,12 +164,12 @@ mod tests { use crate::input::{DrawingState, MouseButton}; #[test] - fn cancel_reports_auto_frozen_ownership() { + fn cancel_returns_the_exact_owned_frozen_generation() { let mut state = make_test_input_state(); state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); - state.activate_eyedropper(true); + state.activate_eyedropper(Some(17)); - assert!(state.cancel_eyedropper()); + assert_eq!(state.cancel_eyedropper(), Some(17)); assert_eq!(state.eyedropper_state(), EyedropperUiState::Inactive); } @@ -182,39 +178,10 @@ mod tests { let mut state = make_test_input_state(); state.set_eyedropper_pending_capture(EyedropperCaptureSource::Zoom); - assert!(!state.cancel_eyedropper()); + assert_eq!(state.cancel_eyedropper(), None); assert_eq!(state.eyedropper_state(), EyedropperUiState::Inactive); } - #[test] - fn capture_failure_preserves_a_more_specific_existing_error() { - let mut state = make_test_input_state(); - state.push_toast( - ToastPriority::Critical, - "eyedropper", - Toast::error("Freeze failed after the display changed size"), - ); - - state.report_eyedropper_capture_failure_if_unreported(); - - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("Freeze failed after the display changed size") - ); - } - - #[test] - fn capture_failure_reports_a_fallback_when_capture_did_not() { - let mut state = make_test_input_state(); - - state.report_eyedropper_capture_failure_if_unreported(); - - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("Screen eyedropper capture failed.") - ); - } - #[test] fn activation_cancels_interaction_started_while_capture_was_pending() { let mut state = make_test_input_state(); @@ -222,7 +189,7 @@ mod tests { state.on_mouse_press(MouseButton::Left, 10, 20); assert!(matches!(state.state, DrawingState::Drawing { .. })); - state.activate_eyedropper(true); + state.activate_eyedropper(Some(1)); assert!(matches!(state.state, DrawingState::Idle)); assert!(state.active_drag_button.is_none()); @@ -239,7 +206,7 @@ mod tests { state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); assert!(state.modal_blocks_canvas_key_repeat()); - state.activate_eyedropper(true); + state.activate_eyedropper(Some(1)); assert!(state.modal_blocks_canvas_key_repeat()); state.cancel_eyedropper(); diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 59649d858..f126c48c4 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -12,9 +12,9 @@ mod index; mod input_hud_controls; mod menus; pub(crate) mod modal; -mod ocr; mod properties; pub(crate) mod radial_menu; +mod region_select; mod selection; mod selection_actions; mod session; @@ -72,7 +72,6 @@ pub use ime::{ImeCompositionState, ImePreedit}; pub use menus::{ ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuState, MenuCommand, }; -pub use ocr::{OcrCaptureSource, OcrInputSource, OcrSelection, OcrUiState}; pub use properties::{SelectionPropertyEntry, SelectionPropertyKind}; pub use radial_menu::{ COMPASS_SLICES as RADIAL_COMPASS_SLICES, CompassDir, RADIAL_PAINT_DELAY, RadialMenuLayout, @@ -81,6 +80,10 @@ pub use radial_menu::{ compass_slice, size_ring_angle_for_value, size_ring_value_for_angle, slice_parent, sub_ring_child_count, sub_ring_children, }; +pub use region_select::{ + RegionInputSource, RegionPurposeTag, RegionSelectUiState, RegionSelection, ScreenCaptureSource, + SelectionPolicy, +}; pub use selection::SelectionState; pub use tool_controls::PrecisionEntryState; pub use tour::TourStep; diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index dbba478b5..78a547305 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -176,7 +176,7 @@ impl InputState { /// It is deliberately not the pen boundary — see /// [`Self::screen_modal_is_active`]. pub(crate) fn screen_modal_is_engaged(&self) -> bool { - self.eyedropper_is_engaged() || self.ocr_is_engaged() + self.eyedropper_is_engaged() || self.region_is_engaged() } /// Whether a screen-region modal is on screen and owns pointer input. @@ -188,6 +188,6 @@ impl InputState { /// keeps its pressure. Activation cancels it; a capture that fails or is /// cancelled first leaves it intact. pub(crate) fn screen_modal_is_active(&self) -> bool { - self.eyedropper_is_active() || self.ocr_is_active() + self.eyedropper_is_active() || self.region_is_active() } } diff --git a/src/input/state/core/ocr.rs b/src/input/state/core/ocr.rs deleted file mode 100644 index 51ffdbf38..000000000 --- a/src/input/state/core/ocr.rs +++ /dev/null @@ -1,531 +0,0 @@ -use super::InputState; -use crate::input::state::{Toast, ToastPriority}; - -/// Which capture the OCR selector is waiting on before it can arm. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OcrCaptureSource { - Frozen, - Zoom, -} - -/// Which physical input started a region drag. -/// -/// A region is one gesture by one device. Wayscriber is modal but the seat is -/// not: a pen can hover or lift while the region is being dragged with the -/// mouse, and without an owner those stray events would move — or submit — -/// somebody else's region. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OcrInputSource { - Pointer, - Touch, - Stylus, -} - -/// A finished or in-progress region drag in logical screen coordinates. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct OcrSelection { - pub start: (f64, f64), - pub end: (f64, f64), - /// OCR created the freeze it selected against, so OCR must release it. - pub auto_froze: bool, -} - -/// UI-facing lifecycle for the modal `Copy text from screen` region selector. -/// -/// It owns transient input and render state only. The selected pixels and the -/// recognition work belong outside `InputState`, so cancelling here can never -/// discard a request that is already running. -#[derive(Debug, Default, Clone, Copy, PartialEq)] -pub enum OcrUiState { - #[default] - Inactive, - PendingCapture { - source: OcrCaptureSource, - auto_froze: bool, - }, - /// The selector is live and waiting for the press that starts a region. - Armed { auto_froze: bool }, - /// A region drag is in progress between `start` and `current`, owned by the - /// device that pressed. Only that device may move, release, or cancel it. - Selecting { - owner: OcrInputSource, - start: (f64, f64), - current: (f64, f64), - auto_froze: bool, - }, -} - -impl OcrUiState { - pub fn is_active(self) -> bool { - matches!(self, Self::Armed { .. } | Self::Selecting { .. }) - } - - pub fn is_pending(self) -> bool { - matches!(self, Self::PendingCapture { .. }) - } - - pub fn is_engaged(self) -> bool { - self.is_active() || self.is_pending() - } - - pub fn is_selecting(self) -> bool { - matches!(self, Self::Selecting { .. }) - } - - /// The region drag in logical screen coordinates, if one is in progress. - pub fn selection(self) -> Option { - match self { - Self::Selecting { - start, - current, - auto_froze, - .. - } => Some(OcrSelection { - start, - end: current, - auto_froze, - }), - Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None, - } - } - - /// The device dragging the region, if one is in progress. - pub fn selection_owner(self) -> Option { - match self { - Self::Selecting { owner, .. } => Some(owner), - Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None, - } - } - - pub fn pending_source(self) -> Option { - match self { - Self::PendingCapture { source, .. } => Some(source), - Self::Inactive | Self::Armed { .. } | Self::Selecting { .. } => None, - } - } - - fn auto_froze(self) -> bool { - match self { - Self::PendingCapture { auto_froze, .. } - | Self::Armed { auto_froze } - | Self::Selecting { auto_froze, .. } => auto_froze, - Self::Inactive => false, - } - } -} - -impl InputState { - pub(crate) fn request_copy_text_from_screen(&mut self) { - self.pending_ocr_request = true; - } - - pub(crate) fn take_pending_ocr_request(&mut self) -> bool { - std::mem::take(&mut self.pending_ocr_request) - } - - /// Record that a toolbar interaction dismissed the selector. - pub(crate) fn note_ocr_cancelled_by_toolbar(&mut self) { - self.ocr_cancelled_by_toolbar = true; - } - - pub(crate) fn take_ocr_cancelled_by_toolbar(&mut self) -> bool { - std::mem::take(&mut self.ocr_cancelled_by_toolbar) - } - - pub fn ocr_state(&self) -> OcrUiState { - self.ocr_ui_state - } - - pub fn ocr_is_active(&self) -> bool { - self.ocr_ui_state.is_active() - } - - pub fn ocr_is_engaged(&self) -> bool { - self.ocr_ui_state.is_engaged() - } - - pub(crate) fn set_ocr_pending_capture(&mut self, source: OcrCaptureSource) { - self.ocr_ui_state = OcrUiState::PendingCapture { - source, - auto_froze: matches!(source, OcrCaptureSource::Frozen), - }; - self.mark_ocr_dirty(); - } - - pub(crate) fn activate_ocr(&mut self, auto_froze: bool) { - // A capture can take long enough for another interaction to begin while - // OCR is pending. Entering the modal state must cancel it so the - // selector cannot swallow the matching release event. - self.prepare_for_screen_modal(); - self.ocr_ui_state = OcrUiState::Armed { auto_froze }; - self.mark_ocr_dirty(); - } - - /// Begin a region drag owned by `owner`. - /// - /// Ignored unless the selector is armed, so a stray press during capture - /// cannot start a region against a stale image — and, because a drag in - /// progress is no longer armed, a second device cannot take one over. - pub(crate) fn start_ocr_selection(&mut self, owner: OcrInputSource, point: (f64, f64)) -> bool { - let OcrUiState::Armed { auto_froze } = self.ocr_ui_state else { - return false; - }; - self.ocr_ui_state = OcrUiState::Selecting { - owner, - start: point, - current: point, - auto_froze, - }; - self.mark_ocr_dirty(); - true - } - - /// Move the region, if `source` is the device dragging it. Motion from any - /// other device is somebody else's and is dropped. - pub(crate) fn update_ocr_selection(&mut self, source: OcrInputSource, point: (f64, f64)) { - if let OcrUiState::Selecting { owner, current, .. } = &mut self.ocr_ui_state - && *owner == source - && *current != point - { - *current = point; - self.mark_ocr_dirty(); - } - } - - /// Whether `source` is the device dragging the region right now. The - /// release and per-device cancellation paths check this before acting, so - /// one device cannot submit or discard another's region. - pub(crate) fn ocr_selection_is_owned_by(&self, source: OcrInputSource) -> bool { - self.ocr_ui_state.selection_owner() == Some(source) - } - - /// Abandon a drag that was too small to be a region and wait for the next - /// one. A mis-click should not drop the user out of the selector, and it - /// must not release a capture the selector is still using. - pub(crate) fn rearm_ocr_selection(&mut self) { - if let OcrUiState::Selecting { auto_froze, .. } = self.ocr_ui_state { - self.ocr_ui_state = OcrUiState::Armed { auto_froze }; - self.mark_ocr_dirty(); - } - } - - /// Leave OCR selection and report whether it created frozen mode itself. - pub(crate) fn cancel_ocr(&mut self) -> bool { - let auto_froze = self.ocr_ui_state.auto_froze(); - if !matches!(self.ocr_ui_state, OcrUiState::Inactive) { - self.ocr_ui_state = OcrUiState::Inactive; - self.mark_ocr_dirty(); - } - auto_froze - } - - pub(crate) fn report_ocr_capture_failure_if_unreported(&mut self) { - if self.ui_toast.is_none() { - self.push_toast( - ToastPriority::Critical, - "ocr", - Toast::error("Screen capture for text recognition failed."), - ); - } - } - - /// The selector paints a full-surface scrim with the selected region cut - /// out of it, so every change repaints the whole surface: an incremental - /// buffer would otherwise keep the scrim from an earlier frame. - fn mark_ocr_dirty(&mut self) { - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::input::state::test_support::make_test_input_state; - use crate::input::{DrawingState, MouseButton, Tool}; - - #[test] - fn cancel_reports_auto_frozen_ownership() { - let mut state = make_test_input_state(); - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); - state.activate_ocr(true); - - assert!(state.cancel_ocr()); - assert_eq!(state.ocr_state(), OcrUiState::Inactive); - } - - #[test] - fn waiting_for_zoom_does_not_claim_frozen_mode() { - let mut state = make_test_input_state(); - state.set_ocr_pending_capture(OcrCaptureSource::Zoom); - - assert!(!state.cancel_ocr()); - assert_eq!(state.ocr_state(), OcrUiState::Inactive); - } - - #[test] - fn a_drag_records_both_corners_and_the_capture_it_owns() { - let mut state = make_test_input_state(); - state.activate_ocr(true); - - assert!(state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 20.0))); - state.update_ocr_selection(OcrInputSource::Pointer, (60.0, 90.0)); - assert!(state.ocr_state().is_selecting()); - - assert_eq!( - state.ocr_state().selection(), - Some(OcrSelection { - start: (10.0, 20.0), - end: (60.0, 90.0), - auto_froze: true, - }) - ); - } - - /// A pen already drawing when the selector opens leaves it `Armed`, never - /// `Selecting`: the tip-up that follows has no region to submit and is - /// swallowed. That is exactly why the backend retires the stylus contact - /// when the modal opens instead of waiting for an `Up` it will consume. - #[test] - fn opening_the_selector_mid_stroke_cancels_the_stroke_and_arms_without_a_region() { - let mut state = make_test_input_state(); - state.on_mouse_press(MouseButton::Left, 10, 20); - state.on_mouse_motion(30, 40); - assert!(matches!(state.state, DrawingState::Drawing { .. })); - - state.activate_ocr(true); - - assert!(matches!(state.state, DrawingState::Idle)); - assert_eq!(state.ocr_state(), OcrUiState::Armed { auto_froze: true }); - assert!(state.ocr_state().selection().is_none()); - } - - #[test] - fn a_press_before_the_capture_lands_does_not_start_a_region() { - let mut state = make_test_input_state(); - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); - - assert!(!state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 20.0))); - assert!(state.ocr_state().selection().is_none()); - } - - #[test] - fn cancelling_mid_drag_yields_no_region() { - let mut state = make_test_input_state(); - state.activate_ocr(true); - state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 20.0)); - - assert!(state.cancel_ocr()); - assert_eq!(state.ocr_state().selection(), None); - // The state is gone, so a second cancel cannot release the freeze twice. - assert!(!state.cancel_ocr()); - } - - /// Clicking the OCR toolbar button while the selector is up toggles it off. - /// - /// The toolbar paths dismiss the selector on the way in — that is what makes - /// every *other* button dismiss it — so by the time the button's own action - /// is drained the state reads `Inactive`. The latch is what tells those two - /// halves apart, and it must not survive the batch that set it. - #[test] - fn the_toolbar_cancel_latch_marks_one_batch_and_then_clears() { - let mut state = make_test_input_state(); - assert!(!state.take_ocr_cancelled_by_toolbar()); - - state.activate_ocr(true); - state.cancel_ocr(); - state.note_ocr_cancelled_by_toolbar(); - state.request_copy_text_from_screen(); - - assert!(state.take_ocr_cancelled_by_toolbar()); - assert!(state.take_pending_ocr_request()); - assert!( - !state.take_ocr_cancelled_by_toolbar(), - "the latch outlived the interaction that set it" - ); - } - - /// The seat is not modal even though Wayscriber is: a pen can hover, and a - /// contact retired when the selector opened keeps reporting until it lifts. - /// Neither may steer a region the mouse is dragging. - #[test] - fn only_the_device_that_started_a_region_can_move_or_finish_it() { - let mut state = make_test_input_state(); - state.activate_ocr(true); - assert!(state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 20.0))); - - state.update_ocr_selection(OcrInputSource::Stylus, (500.0, 500.0)); - state.update_ocr_selection(OcrInputSource::Touch, (400.0, 400.0)); - assert_eq!( - state.ocr_state().selection().map(|region| region.end), - Some((10.0, 20.0)), - "another device dragged the pointer's region" - ); - - state.update_ocr_selection(OcrInputSource::Pointer, (60.0, 90.0)); - assert_eq!( - state.ocr_state().selection().map(|region| region.end), - Some((60.0, 90.0)) - ); - - assert!(!state.ocr_selection_is_owned_by(OcrInputSource::Stylus)); - assert!(!state.ocr_selection_is_owned_by(OcrInputSource::Touch)); - assert!(state.ocr_selection_is_owned_by(OcrInputSource::Pointer)); - } - - /// A second device pressing mid-drag must not take the region over: the - /// selector is no longer armed, so its press finds nothing to start. - #[test] - fn a_second_device_cannot_take_over_a_region_in_progress() { - let mut state = make_test_input_state(); - state.activate_ocr(true); - assert!(state.start_ocr_selection(OcrInputSource::Touch, (10.0, 20.0))); - - assert!(!state.start_ocr_selection(OcrInputSource::Pointer, (200.0, 200.0))); - - assert!(state.ocr_selection_is_owned_by(OcrInputSource::Touch)); - assert_eq!( - state.ocr_state().selection().map(|region| region.start), - Some((10.0, 20.0)) - ); - } - - /// Ownership is per drag, not per session: after one device's region ends, - /// the re-armed selector accepts the next press from any device. - #[test] - fn ownership_resets_with_each_region() { - let mut state = make_test_input_state(); - state.activate_ocr(true); - state.start_ocr_selection(OcrInputSource::Stylus, (10.0, 20.0)); - state.rearm_ocr_selection(); - - assert!(state.ocr_state().selection_owner().is_none()); - assert!(state.start_ocr_selection(OcrInputSource::Pointer, (30.0, 40.0))); - assert!(state.ocr_selection_is_owned_by(OcrInputSource::Pointer)); - } - - #[test] - fn a_mis_click_rearms_the_selector_instead_of_leaving_it() { - let mut state = make_test_input_state(); - state.activate_ocr(true); - state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 20.0)); - - state.rearm_ocr_selection(); - - assert_eq!(state.ocr_state(), OcrUiState::Armed { auto_froze: true }); - assert!(state.ocr_is_active()); - } - - #[test] - fn activation_cancels_an_interaction_started_while_capture_was_pending() { - let mut state = make_test_input_state(); - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); - state.on_mouse_press(MouseButton::Left, 10, 20); - assert!(matches!(state.state, DrawingState::Drawing { .. })); - - state.activate_ocr(true); - - assert!(matches!(state.state, DrawingState::Idle)); - assert!(state.active_drag_button.is_none()); - assert!(state.ocr_is_active()); - // The gesture cancelled here started during the wait, not at the - // request — and the selector arms with no region, so the release that - // ends it is swallowed. Activation is therefore the only point that can - // retire the backend contact behind it. - assert_eq!(state.ocr_state(), OcrUiState::Armed { auto_froze: true }); - assert!(state.ocr_state().selection().is_none()); - } - - /// OCR is an action, not a tool: whatever the user was drawing with before - /// must still be selected after the selector opens, drags, and closes. - #[test] - fn the_whole_selector_lifecycle_preserves_every_representative_tool() { - for tool in [ - Tool::Pen, - Tool::Select, - Tool::Marker, - Tool::Eraser, - Tool::Arrow, - Tool::Highlight, - Tool::Blur, - ] { - let mut state = make_test_input_state(); - state.set_tool_override(Some(tool)); - - state.request_copy_text_from_screen(); - assert!(state.take_pending_ocr_request()); - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); - state.activate_ocr(true); - state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 10.0)); - state.update_ocr_selection(OcrInputSource::Pointer, (80.0, 60.0)); - state.cancel_ocr(); - - assert_eq!( - state.active_tool(), - tool, - "OCR changed the tool away from {tool:?}" - ); - } - } - - /// Nothing about a recognition is a canvas edit. One undo after the whole - /// lifecycle must remove the stroke drawn before it — if OCR had pushed a - /// history entry, that undo would have been spent on OCR instead. - #[test] - fn the_selector_lifecycle_adds_no_shape_and_no_history_entry() { - let mut state = make_test_input_state(); - state.on_mouse_press(MouseButton::Left, 0, 0); - state.on_mouse_motion(10, 10); - state.on_mouse_release(MouseButton::Left, 10, 10); - assert_eq!(state.boards.active_frame().shapes.len(), 1); - - state.activate_ocr(true); - state.start_ocr_selection(OcrInputSource::Pointer, (10.0, 10.0)); - state.update_ocr_selection(OcrInputSource::Pointer, (120.0, 90.0)); - state.cancel_ocr(); - assert_eq!(state.boards.active_frame().shapes.len(), 1); - - state.handle_action(crate::domain::Action::Undo); - - assert_eq!(state.boards.active_frame().shapes.len(), 0); - } - - /// The selector swallows every key press it receives. A key still held - /// from before it opened — from the toolbar, the palette, or a mouse path — - /// must not keep repeating into the canvas behind it. - #[test] - fn an_engaged_selector_owns_the_keyboard_and_stops_canvas_key_repeat() { - let mut state = make_test_input_state(); - assert!(!state.modal_blocks_canvas_key_repeat()); - assert!(!state.modal_owns_text_input()); - - state.set_ocr_pending_capture(OcrCaptureSource::Frozen); - assert!(state.modal_blocks_canvas_key_repeat()); - assert!(state.modal_owns_text_input()); - - state.activate_ocr(true); - assert!(state.modal_blocks_canvas_key_repeat()); - assert!(state.modal_owns_text_input()); - - state.cancel_ocr(); - assert!(!state.modal_blocks_canvas_key_repeat()); - assert!(!state.modal_owns_text_input()); - } - - #[test] - fn capture_failure_preserves_a_more_specific_existing_error() { - let mut state = make_test_input_state(); - state.push_toast( - ToastPriority::Critical, - "ocr", - Toast::error("Freeze failed after the display changed size"), - ); - - state.report_ocr_capture_failure_if_unreported(); - - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("Freeze failed after the display changed size") - ); - } -} diff --git a/src/input/state/core/region_select.rs b/src/input/state/core/region_select.rs new file mode 100644 index 000000000..38f521b25 --- /dev/null +++ b/src/input/state/core/region_select.rs @@ -0,0 +1,620 @@ +use super::InputState; + +/// Why the generalized screen-region selector is engaged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegionPurposeTag { + Ocr, + CaptureDeliver, + CaptureInteractive, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SelectionPolicy { + min_submit_logical_px: Option, + allow_square: bool, + snap_anchor: bool, +} + +impl SelectionPolicy { + pub const fn min_submit_logical_px(self) -> Option { + self.min_submit_logical_px + } + + pub const fn allow_square(self) -> bool { + self.allow_square + } + + pub const fn snap_anchor(self) -> bool { + self.snap_anchor + } +} + +impl RegionPurposeTag { + pub const fn selection_policy(self) -> SelectionPolicy { + match self { + Self::Ocr => SelectionPolicy { + min_submit_logical_px: Some(4.0), + allow_square: false, + snap_anchor: false, + }, + Self::CaptureDeliver | Self::CaptureInteractive => SelectionPolicy { + min_submit_logical_px: None, + allow_square: true, + snap_anchor: true, + }, + } + } +} + +/// Which captured screen source a region selector is waiting on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScreenCaptureSource { + Frozen, + Zoom, +} + +/// Which physical input started a region drag. +/// +/// A region is one gesture by one device. Wayscriber is modal but the seat is +/// not: a pen can hover or lift while the region is being dragged with the +/// mouse, and without an owner those stray events would move — or submit — +/// somebody else's region. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegionInputSource { + Pointer, + Touch, + Stylus, +} + +/// A finished or in-progress region drag in logical screen coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RegionSelection { + pub start: (f64, f64), + pub end: (f64, f64), +} + +/// UI-facing lifecycle for the modal `Copy text from screen` region selector. +/// +/// It owns transient input and render state only. The selected pixels and the +/// recognition work belong outside `InputState`, so cancelling here can never +/// discard a request that is already running. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum RegionSelectUiState { + #[default] + Inactive, + PendingCapture { + purpose: RegionPurposeTag, + generation: u64, + source: ScreenCaptureSource, + }, + /// The selector is live and waiting for the press that starts a region. + Armed { + purpose: RegionPurposeTag, + generation: u64, + }, + /// A region drag is in progress between `start` and `current`, owned by the + /// device that pressed. Only that device may move, release, or cancel it. + Selecting { + purpose: RegionPurposeTag, + generation: u64, + owner: RegionInputSource, + start: (f64, f64), + current: (f64, f64), + }, +} + +impl RegionSelectUiState { + pub fn is_active(self) -> bool { + matches!(self, Self::Armed { .. } | Self::Selecting { .. }) + } + + pub fn is_pending(self) -> bool { + matches!(self, Self::PendingCapture { .. }) + } + + pub fn is_engaged(self) -> bool { + self.is_active() || self.is_pending() + } + + pub fn is_selecting(self) -> bool { + matches!(self, Self::Selecting { .. }) + } + + /// The region drag in logical screen coordinates, if one is in progress. + pub fn selection(self) -> Option { + match self { + Self::Selecting { start, current, .. } => Some(RegionSelection { + start, + end: current, + }), + Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None, + } + } + + /// The device dragging the region, if one is in progress. + pub fn selection_owner(self) -> Option { + match self { + Self::Selecting { owner, .. } => Some(owner), + Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None, + } + } + + pub fn pending_source(self) -> Option { + match self { + Self::PendingCapture { source, .. } => Some(source), + Self::Inactive | Self::Armed { .. } | Self::Selecting { .. } => None, + } + } + + pub fn purpose(self) -> Option { + match self { + Self::PendingCapture { purpose, .. } + | Self::Armed { purpose, .. } + | Self::Selecting { purpose, .. } => Some(purpose), + Self::Inactive => None, + } + } + + pub fn generation(self) -> Option { + match self { + Self::PendingCapture { generation, .. } + | Self::Armed { generation, .. } + | Self::Selecting { generation, .. } => Some(generation), + Self::Inactive => None, + } + } +} + +impl InputState { + pub(crate) fn request_copy_text_from_screen(&mut self) { + self.pending_ocr_request = true; + } + + pub(crate) fn take_pending_ocr_request(&mut self) -> bool { + std::mem::take(&mut self.pending_ocr_request) + } + + /// Record that a toolbar interaction dismissed the selector. + pub(crate) fn note_ocr_cancelled_by_toolbar(&mut self) { + self.ocr_cancelled_by_toolbar = true; + } + + pub(crate) fn take_ocr_cancelled_by_toolbar(&mut self) -> bool { + std::mem::take(&mut self.ocr_cancelled_by_toolbar) + } + + pub fn region_state(&self) -> RegionSelectUiState { + self.region_select_ui_state + } + + pub fn region_is_active(&self) -> bool { + self.region_select_ui_state.is_active() + } + + pub fn region_is_engaged(&self) -> bool { + self.region_select_ui_state.is_engaged() + } + + pub(crate) fn set_region_pending_capture( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + source: ScreenCaptureSource, + ) { + self.region_select_ui_state = RegionSelectUiState::PendingCapture { + purpose, + generation, + source, + }; + self.mark_region_dirty(); + } + + pub(crate) fn activate_region(&mut self, purpose: RegionPurposeTag, generation: u64) { + // A capture can take long enough for another interaction to begin while + // OCR is pending. Entering the modal state must cancel it so the + // selector cannot swallow the matching release event. + self.prepare_for_screen_modal(); + self.region_select_ui_state = RegionSelectUiState::Armed { + purpose, + generation, + }; + self.mark_region_dirty(); + } + + /// Begin a region drag owned by `owner`. + /// + /// Ignored unless the selector is armed, so a stray press during capture + /// cannot start a region against a stale image — and, because a drag in + /// progress is no longer armed, a second device cannot take one over. + pub(crate) fn start_region_selection( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + let RegionSelectUiState::Armed { + purpose, + generation, + } = self.region_select_ui_state + else { + return false; + }; + self.region_select_ui_state = RegionSelectUiState::Selecting { + purpose, + generation, + owner, + start: point, + current: point, + }; + self.mark_region_dirty(); + true + } + + /// Move the region, if `source` is the device dragging it. Motion from any + /// other device is somebody else's and is dropped. + pub(crate) fn update_region_selection(&mut self, source: RegionInputSource, point: (f64, f64)) { + if let RegionSelectUiState::Selecting { owner, current, .. } = + &mut self.region_select_ui_state + && *owner == source + && *current != point + { + *current = point; + self.mark_region_dirty(); + } + } + + /// Whether `source` is the device dragging the region right now. The + /// release and per-device cancellation paths check this before acting, so + /// one device cannot submit or discard another's region. + pub(crate) fn region_selection_is_owned_by(&self, source: RegionInputSource) -> bool { + self.region_select_ui_state.selection_owner() == Some(source) + } + + /// Abandon a drag that was too small to be a region and wait for the next + /// one. A mis-click should not drop the user out of the selector, and it + /// must not release a capture the selector is still using. + pub(crate) fn rearm_region_selection(&mut self) { + if let RegionSelectUiState::Selecting { + purpose, + generation, + .. + } = self.region_select_ui_state + { + self.region_select_ui_state = RegionSelectUiState::Armed { + purpose, + generation, + }; + self.mark_region_dirty(); + } + } + + pub(crate) fn cancel_region_ui_only(&mut self) { + if !matches!(self.region_select_ui_state, RegionSelectUiState::Inactive) { + self.region_select_ui_state = RegionSelectUiState::Inactive; + self.mark_region_dirty(); + } + } + + /// The selector paints a full-surface scrim with the selected region cut + /// out of it, so every change repaints the whole surface: an incremental + /// buffer would otherwise keep the scrim from an earlier frame. + fn mark_region_dirty(&mut self) { + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + use crate::input::{DrawingState, MouseButton, Tool}; + + // Frozen-capture ownership and capture-failure reporting intentionally no + // longer live in InputState; their regressions belong to the backend + // acquisition and routing tests. This suite preserves the former OCR UI, + // interaction, tool, and history guarantees at their generalized seam. + + fn activate_ocr_region(state: &mut InputState, generation: u64) { + state.activate_region(RegionPurposeTag::Ocr, generation); + } + + #[test] + fn region_policy_preserves_ocr_geometry_and_reserves_capture_geometry() { + let ocr = RegionPurposeTag::Ocr.selection_policy(); + assert_eq!(ocr.min_submit_logical_px(), Some(4.0)); + assert!(!ocr.allow_square()); + assert!(!ocr.snap_anchor()); + + for purpose in [ + RegionPurposeTag::CaptureDeliver, + RegionPurposeTag::CaptureInteractive, + ] { + let capture = purpose.selection_policy(); + assert_eq!(capture.min_submit_logical_px(), None); + assert!(capture.allow_square()); + assert!(capture.snap_anchor()); + } + } + + #[test] + fn neutral_region_ui_tracks_purpose_generation_source_and_raw_preview() { + let mut state = make_test_input_state(); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 17, ScreenCaptureSource::Frozen); + assert_eq!(state.region_state().generation(), Some(17)); + assert_eq!(state.region_state().purpose(), Some(RegionPurposeTag::Ocr)); + assert_eq!( + state.region_state().pending_source(), + Some(ScreenCaptureSource::Frozen) + ); + + activate_ocr_region(&mut state, 17); + assert!(state.start_region_selection(RegionInputSource::Pointer, (10.25, 20.75),)); + state.update_region_selection(RegionInputSource::Pointer, (14.5, 27.125)); + + assert_eq!( + state.region_state().selection(), + Some(RegionSelection { + start: (10.25, 20.75), + end: (14.5, 27.125), + }) + ); + assert_eq!(state.region_state().generation(), Some(17)); + } + + #[test] + fn device_ownership_rearm_and_cancel_are_purpose_neutral() { + let mut state = make_test_input_state(); + activate_ocr_region(&mut state, 3); + assert!(state.start_region_selection(RegionInputSource::Pointer, (10.0, 20.0))); + state.update_region_selection(RegionInputSource::Stylus, (500.0, 500.0)); + assert_eq!( + state.region_state().selection().map(|region| region.end), + Some((10.0, 20.0)) + ); + state.update_region_selection(RegionInputSource::Pointer, (60.0, 90.0)); + assert!(state.region_selection_is_owned_by(RegionInputSource::Pointer)); + + state.rearm_region_selection(); + assert_eq!( + state.region_state(), + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::Ocr, + generation: 3, + } + ); + state.cancel_region_ui_only(); + assert_eq!(state.region_state(), RegionSelectUiState::Inactive); + } + + #[test] + fn a_press_before_the_capture_lands_does_not_start_a_region() { + let mut state = make_test_input_state(); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 4, ScreenCaptureSource::Frozen); + + assert!(!state.start_region_selection(RegionInputSource::Pointer, (10.0, 20.0))); + assert!(state.region_state().selection().is_none()); + assert_eq!( + state.region_state().pending_source(), + Some(ScreenCaptureSource::Frozen) + ); + } + + #[test] + fn waiting_for_zoom_can_be_cancelled_without_leaving_region_state() { + let mut state = make_test_input_state(); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 5, ScreenCaptureSource::Zoom); + + assert_eq!( + state.region_state().pending_source(), + Some(ScreenCaptureSource::Zoom) + ); + state.cancel_region_ui_only(); + + assert_eq!(state.region_state(), RegionSelectUiState::Inactive); + assert!(state.region_state().selection().is_none()); + } + + #[test] + fn cancelling_mid_drag_yields_no_region_and_a_second_cancel_is_a_no_op() { + let mut state = make_test_input_state(); + activate_ocr_region(&mut state, 5); + assert!(state.start_region_selection(RegionInputSource::Pointer, (10.0, 20.0))); + + state.cancel_region_ui_only(); + assert_eq!(state.region_state(), RegionSelectUiState::Inactive); + assert!(state.region_state().selection().is_none()); + + state.cancel_region_ui_only(); + assert_eq!(state.region_state(), RegionSelectUiState::Inactive); + } + + /// Clicking the OCR toolbar button while the selector is up toggles it off. + /// The cancellation latch belongs to that input batch and must not survive it. + #[test] + fn the_toolbar_cancel_latch_marks_one_batch_and_then_clears() { + let mut state = make_test_input_state(); + assert!(!state.take_ocr_cancelled_by_toolbar()); + + activate_ocr_region(&mut state, 6); + state.cancel_region_ui_only(); + state.note_ocr_cancelled_by_toolbar(); + state.request_copy_text_from_screen(); + + assert!(state.take_ocr_cancelled_by_toolbar()); + assert!(state.take_pending_ocr_request()); + assert!( + !state.take_ocr_cancelled_by_toolbar(), + "the latch outlived the interaction that set it" + ); + } + + #[test] + fn only_the_device_that_started_a_region_can_move_it() { + let mut state = make_test_input_state(); + activate_ocr_region(&mut state, 7); + assert!(state.start_region_selection(RegionInputSource::Pointer, (10.0, 20.0))); + + state.update_region_selection(RegionInputSource::Stylus, (500.0, 500.0)); + state.update_region_selection(RegionInputSource::Touch, (400.0, 400.0)); + assert_eq!( + state.region_state().selection().map(|region| region.end), + Some((10.0, 20.0)), + "another device dragged the pointer's region" + ); + + state.update_region_selection(RegionInputSource::Pointer, (60.0, 90.0)); + assert_eq!( + state.region_state().selection().map(|region| region.end), + Some((60.0, 90.0)) + ); + assert!(!state.region_selection_is_owned_by(RegionInputSource::Stylus)); + assert!(!state.region_selection_is_owned_by(RegionInputSource::Touch)); + assert!(state.region_selection_is_owned_by(RegionInputSource::Pointer)); + } + + #[test] + fn a_second_device_cannot_take_over_a_region_in_progress() { + let mut state = make_test_input_state(); + activate_ocr_region(&mut state, 8); + assert!(state.start_region_selection(RegionInputSource::Touch, (10.0, 20.0))); + + assert!(!state.start_region_selection(RegionInputSource::Pointer, (200.0, 200.0))); + assert!(state.region_selection_is_owned_by(RegionInputSource::Touch)); + assert_eq!( + state.region_state().selection().map(|region| region.start), + Some((10.0, 20.0)) + ); + } + + #[test] + fn ownership_resets_with_each_region() { + let mut state = make_test_input_state(); + activate_ocr_region(&mut state, 9); + assert!(state.start_region_selection(RegionInputSource::Stylus, (10.0, 20.0))); + state.rearm_region_selection(); + + assert!(state.region_state().selection_owner().is_none()); + assert!(state.start_region_selection(RegionInputSource::Pointer, (30.0, 40.0))); + assert!(state.region_selection_is_owned_by(RegionInputSource::Pointer)); + } + + #[test] + fn opening_the_selector_mid_stroke_cancels_the_stroke_and_arms_without_a_region() { + let mut state = make_test_input_state(); + state.set_tool_override(Some(Tool::Marker)); + state.on_mouse_press(MouseButton::Left, 10, 20); + state.on_mouse_motion(30, 40); + assert!(matches!(state.state, DrawingState::Drawing { .. })); + + activate_ocr_region(&mut state, 10); + + assert!(matches!(state.state, DrawingState::Idle)); + assert!(state.active_drag_button.is_none()); + assert_eq!(state.active_tool(), Tool::Marker); + assert_eq!( + state.region_state(), + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::Ocr, + generation: 10, + } + ); + assert!(state.region_state().selection().is_none()); + } + + #[test] + fn activation_cancels_an_interaction_started_while_capture_was_pending() { + let mut state = make_test_input_state(); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 11, ScreenCaptureSource::Frozen); + state.on_mouse_press(MouseButton::Left, 10, 20); + assert!(matches!(state.state, DrawingState::Drawing { .. })); + + activate_ocr_region(&mut state, 11); + + assert!(matches!(state.state, DrawingState::Idle)); + assert!(state.active_drag_button.is_none()); + assert_eq!( + state.region_state(), + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::Ocr, + generation: 11, + } + ); + assert!(state.region_state().selection().is_none()); + } + + /// OCR is an action, not a tool: the whole selector lifecycle must preserve + /// whichever representative drawing tool the user had selected. + #[test] + fn the_whole_selector_lifecycle_preserves_every_representative_tool() { + for tool in [ + Tool::Pen, + Tool::Select, + Tool::Marker, + Tool::Eraser, + Tool::Arrow, + Tool::Highlight, + Tool::Blur, + ] { + let mut state = make_test_input_state(); + state.set_tool_override(Some(tool)); + + state.request_copy_text_from_screen(); + assert!(state.take_pending_ocr_request()); + state.set_region_pending_capture( + RegionPurposeTag::Ocr, + 12, + ScreenCaptureSource::Frozen, + ); + activate_ocr_region(&mut state, 12); + assert!(state.start_region_selection(RegionInputSource::Pointer, (10.0, 10.0))); + state.update_region_selection(RegionInputSource::Pointer, (80.0, 60.0)); + state.cancel_region_ui_only(); + + assert_eq!( + state.active_tool(), + tool, + "OCR changed the tool away from {tool:?}" + ); + } + } + + /// Region selection is not a canvas edit. One undo after the lifecycle must + /// remove the stroke drawn before it, proving no region history entry was added. + #[test] + fn the_selector_lifecycle_adds_no_shape_and_no_history_entry() { + let mut state = make_test_input_state(); + state.on_mouse_press(MouseButton::Left, 0, 0); + state.on_mouse_motion(10, 10); + state.on_mouse_release(MouseButton::Left, 10, 10); + assert_eq!(state.boards.active_frame().shapes.len(), 1); + + activate_ocr_region(&mut state, 13); + assert!(state.start_region_selection(RegionInputSource::Pointer, (10.0, 10.0))); + state.update_region_selection(RegionInputSource::Pointer, (120.0, 90.0)); + state.cancel_region_ui_only(); + assert_eq!(state.boards.active_frame().shapes.len(), 1); + + state.handle_action(crate::domain::Action::Undo); + + assert_eq!(state.boards.active_frame().shapes.len(), 0); + } + + #[test] + fn engaged_region_selector_owns_modal_keyboard_and_text_input() { + let mut state = make_test_input_state(); + assert!(!state.modal_blocks_canvas_key_repeat()); + assert!(!state.modal_owns_text_input()); + + state.set_region_pending_capture(RegionPurposeTag::Ocr, 5, ScreenCaptureSource::Frozen); + assert!(state.modal_blocks_canvas_key_repeat()); + assert!(state.modal_owns_text_input()); + + activate_ocr_region(&mut state, 5); + assert!(state.modal_blocks_canvas_key_repeat()); + assert!(state.modal_owns_text_input()); + + state.cancel_region_ui_only(); + assert!(!state.modal_blocks_canvas_key_repeat()); + assert!(!state.modal_owns_text_input()); + } +} diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index d9ceb2e95..8a883697b 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -38,6 +38,21 @@ impl InputState { self.ui_toast.is_none() && self.toast_queue.is_empty() } + #[cfg(test)] + pub(crate) fn test_toast_count(&self) -> usize { + usize::from(self.ui_toast.is_some()) + self.toast_queue.pending_len() + } + + #[cfg(test)] + pub(crate) fn test_active_toast_message(&self) -> Option<&str> { + self.ui_toast.as_ref().map(|toast| toast.message.as_str()) + } + + #[cfg(test)] + pub(crate) fn test_active_toast_key(&self) -> Option<&'static str> { + self.ui_toast.as_ref().map(|toast| toast.key) + } + #[allow(dead_code)] pub(crate) fn set_capture_feedback( &mut self, diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index b745f3cc1..ddc7800d0 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -29,17 +29,17 @@ pub use core::{ ContextMenuEntry, ContextMenuKind, ContextMenuState, DesktopEnvironment, DrawingState, EyedropperCaptureSource, EyedropperUiState, HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome, ImeCompositionState, ImePreedit, InputState, MAX_STROKE_THICKNESS, - MIN_STROKE_THICKNESS, OcrCaptureSource, OcrInputSource, OcrSelection, OcrUiState, - OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PickerDrag, - PrecisionEntryState, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, + MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, + PickerDrag, PrecisionEntryState, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, PressureThicknessEntryMode, QuickColorEdit, RADIAL_COMPASS_SLICES, RADIAL_PAINT_DELAY, RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialParent, RadialRingSwatch, - RadialSegmentId, RadialSlice, RadialSliceKind, SIZE_RING_ARC_SPAN, SIZE_RING_ARC_START, - SelectionAxis, SelectionHandle, SelectionPropertyEntry, SelectionPropertyKind, SelectionState, - ShellMode, TextInputMode, Toast, ToastPriority, ToastPushOutcome, ToastQueue, TourStep, - UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, color_picker_rgb_to_hsv, compass_slice, - size_ring_angle_for_value, size_ring_value_for_angle, slice_parent, sub_ring_child_count, - sub_ring_children, + RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, RegionPurposeTag, + RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, SIZE_RING_ARC_START, + ScreenCaptureSource, SelectionAxis, SelectionHandle, SelectionPolicy, SelectionPropertyEntry, + SelectionPropertyKind, SelectionState, ShellMode, TextInputMode, Toast, ToastPriority, + ToastPushOutcome, ToastQueue, TourStep, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, + color_picker_rgb_to_hsv, compass_slice, size_ring_angle_for_value, size_ring_value_for_angle, + slice_parent, sub_ring_child_count, sub_ring_children, }; pub(crate) use core::{ COMMAND_PALETTE_INPUT_HEIGHT, COMMAND_PALETTE_ITEM_HEIGHT, COMMAND_PALETTE_LIST_GAP, diff --git a/src/lib.rs b/src/lib.rs index 871b3f726..253cccf87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod paths; mod process_broker; pub mod render_profiles; pub mod runtime_capabilities; +pub(crate) mod screen_pixels; // Phases 2-3 establish the controller and storage contracts before later // phases route live UI producers through them. #[allow(dead_code)] diff --git a/src/ocr/controller.rs b/src/ocr/controller.rs index c5c72368e..b9bd185e1 100644 --- a/src/ocr/controller.rs +++ b/src/ocr/controller.rs @@ -283,9 +283,8 @@ mod tests { use super::*; use crate::backend::wayland::RuntimeWakeSource; - use crate::ocr::{ - OcrFailure, OcrLanguages, OcrPixels, OcrSuccess, RecognizedOutput, RecognizedText, - }; + use crate::ocr::{OcrFailure, OcrLanguages, OcrSuccess, RecognizedOutput, RecognizedText}; + use crate::screen_pixels::PackedArgb32; struct FakeRecognizer { outcome: fn() -> Result, @@ -347,12 +346,7 @@ mod tests { fn request() -> OcrRequest { OcrRequest { - pixels: OcrPixels { - width: 2, - height: 2, - stride: 8, - data: vec![0xFF; 16], - }, + pixels: PackedArgb32::new(2, 2, 8, vec![0xFF; 16]).unwrap(), languages: OcrLanguages::from_validated("eng".to_string()), } } diff --git a/src/ocr/mod.rs b/src/ocr/mod.rs index 4be4ac616..c3064bd5b 100644 --- a/src/ocr/mod.rs +++ b/src/ocr/mod.rs @@ -8,6 +8,9 @@ use std::fmt; +use crate::capture::png::encode_packed_argb32_png; +use crate::screen_pixels::PackedArgb32; + mod controller; mod tesseract; @@ -29,30 +32,6 @@ pub(crate) trait OcrTextPublisher { fn publish(&self, text: &str) -> Result<(), OcrFailure>; } -/// Tightly packed premultiplied ARGB32 pixels in Cairo's native byte order. -/// -/// Owned by the request: the crop is taken from the displayed screen image on -/// the event thread and handed to the worker, so a later freeze, zoom, or -/// display change cannot change what is recognized. -pub(crate) struct OcrPixels { - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) stride: i32, - pub(crate) data: Vec, -} - -impl fmt::Debug for OcrPixels { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("OcrPixels") - .field("width", &self.width) - .field("height", &self.height) - .field("stride", &self.stride) - .field("bytes", &self.data.len()) - .finish() - } -} - /// A validated Tesseract language argument, such as `eng` or `eng+deu`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct OcrLanguages(String); @@ -71,7 +50,7 @@ impl OcrLanguages { #[derive(Debug)] pub(crate) struct OcrRequest { - pub(crate) pixels: OcrPixels, + pub(crate) pixels: PackedArgb32, pub(crate) languages: OcrLanguages, } @@ -184,8 +163,11 @@ fn run_request( recognizer: &dyn TextRecognizer, publisher: &dyn OcrTextPublisher, ) -> OcrOutcome { - let png = encode_png(&request.pixels)?; - let recognized = recognizer.recognize(&png, &request.languages)?; + let png = encode_packed_argb32_png(&request.pixels).map_err(|err| { + log::warn!("OCR crop PNG encoding failed: {err}"); + OcrFailure::EncodeFailed + })?; + let recognized = recognizer.recognize(&png.bytes, &request.languages)?; if recognized.text.is_empty() { return Ok(OcrSuccess::NoTextFound); } @@ -204,31 +186,6 @@ pub(crate) struct RecognizedOutput { pub(crate) replaced_invalid_utf8: bool, } -fn encode_png(pixels: &OcrPixels) -> Result, OcrFailure> { - if pixels.width == 0 || pixels.height == 0 { - return Err(OcrFailure::EncodeFailed); - } - let width = i32::try_from(pixels.width).map_err(|_| OcrFailure::EncodeFailed)?; - let height = i32::try_from(pixels.height).map_err(|_| OcrFailure::EncodeFailed)?; - let surface = cairo::ImageSurface::create_for_data( - pixels.data.clone(), - cairo::Format::ARgb32, - width, - height, - pixels.stride, - ) - .map_err(|err| { - log::warn!("OCR crop surface creation failed: {err}"); - OcrFailure::EncodeFailed - })?; - let mut bytes = Vec::new(); - surface.write_to_png(&mut bytes).map_err(|err| { - log::warn!("OCR crop PNG encoding failed: {err}"); - OcrFailure::EncodeFailed - })?; - Ok(bytes) -} - #[cfg(test)] mod tests { use super::*; @@ -262,13 +219,14 @@ mod tests { } } - pub(super) fn pixels(width: u32, height: u32) -> OcrPixels { - OcrPixels { + pub(super) fn pixels(width: u32, height: u32) -> PackedArgb32 { + PackedArgb32::new( width, height, - stride: (width * 4) as i32, - data: vec![0xFF; (width * height * 4) as usize], - } + (width * 4) as i32, + vec![0xFF; (width * height * 4) as usize], + ) + .unwrap() } fn request() -> OcrRequest { @@ -383,31 +341,19 @@ mod tests { } #[test] - fn encoding_rejects_an_empty_crop_before_touching_cairo() { - assert_eq!(encode_png(&pixels(0, 4)), Err(OcrFailure::EncodeFailed)); - assert_eq!(encode_png(&pixels(4, 0)), Err(OcrFailure::EncodeFailed)); - } - - #[test] - fn encoding_preserves_opaque_transparent_and_premultiplied_pixels() { - let source = OcrPixels { - width: 3, - height: 1, - stride: 12, - // Cairo ARgb32 is native-endian premultiplied BGRA on little-endian - // targets: opaque blue, fully transparent, half-alpha grey. - data: vec![255, 0, 0, 255, 0, 0, 0, 0, 64, 64, 64, 128], + fn empty_crop_still_maps_png_failure_to_the_ocr_failure_contract() { + let request = OcrRequest { + pixels: pixels(0, 4), + languages: OcrLanguages::from_validated("eng".to_string()), }; + let publisher = RecordingPublisher(std::sync::Mutex::new(Vec::new())); + let outcome = run_request( + request, + &StubRecognizer(|| panic!("empty pixels must not reach the recognizer")), + &publisher, + ); - let png = encode_png(&source).expect("crop encodes"); - let decoded = - cairo::ImageSurface::create_from_png(&mut png.as_slice()).expect("encoded PNG decodes"); - assert_eq!((decoded.width(), decoded.height()), (3, 1)); - - let mut decoded = decoded; - let stride = decoded.stride() as usize; - let data = decoded.data().expect("decoded pixels"); - assert_eq!(&data[..12], &source.data[..12]); - assert!(stride >= 12); + assert_eq!(outcome, Err(OcrFailure::EncodeFailed)); + assert!(publisher.0.lock().unwrap().is_empty()); } } diff --git a/src/screen_pixels.rs b/src/screen_pixels.rs new file mode 100644 index 000000000..6d5c52c62 --- /dev/null +++ b/src/screen_pixels.rs @@ -0,0 +1,642 @@ +// Phase 0a establishes capture-purpose pixel geometry whose remaining callers +// arrive with the native region picker in Phase 1. +#![allow(dead_code)] + +/// A point in source-image pixel-edge coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ImagePoint { + pub x: f64, + pub y: f64, +} + +impl ImagePoint { + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +fn valid_point_and_bounds(point: ImagePoint, bounds: (u32, u32)) -> bool { + point.x.is_finite() && point.y.is_finite() && bounds.0 != 0 && bounds.1 != 0 +} + +/// Snap an image point to the top-left index of an existing pixel. +pub fn snap_anchor(point: ImagePoint, bounds: (u32, u32)) -> Option { + if !valid_point_and_bounds(point, bounds) { + return None; + } + Some(ImagePoint::new( + point.x.floor().clamp(0.0, f64::from(bounds.0 - 1)), + point.y.floor().clamp(0.0, f64::from(bounds.1 - 1)), + )) +} + +/// Clamp an image point to the inclusive outer pixel-edge domain. +pub fn clamp_edge(point: ImagePoint, bounds: (u32, u32)) -> Option { + if !valid_point_and_bounds(point, bounds) { + return None; + } + Some(ImagePoint::new( + point.x.clamp(0.0, f64::from(bounds.0)), + point.y.clamp(0.0, f64::from(bounds.1)), + )) +} + +/// Constrain an edge to a dominant-axis square rooted at `anchor`. +pub fn squared_cursor( + anchor: ImagePoint, + raw_edge: ImagePoint, + bounds: (u32, u32), +) -> Option { + if !valid_point_and_bounds(anchor, bounds) + || !raw_edge.x.is_finite() + || !raw_edge.y.is_finite() + || anchor.x < 0.0 + || anchor.y < 0.0 + || anchor.x > f64::from(bounds.0 - 1) + || anchor.y > f64::from(bounds.1 - 1) + { + return None; + } + + let delta_x = raw_edge.x - anchor.x; + let delta_y = raw_edge.y - anchor.y; + let mut side = delta_x.abs().max(delta_y.abs()); + if !side.is_finite() { + return None; + } + + let (direction_x, room_x) = square_direction_and_room(delta_x, anchor.x, bounds.0); + let (direction_y, room_y) = square_direction_and_room(delta_y, anchor.y, bounds.1); + side = side.min(room_x).min(room_y); + Some(ImagePoint::new( + anchor.x + direction_x * side, + anchor.y + direction_y * side, + )) +} + +fn square_direction_and_room(delta: f64, anchor: f64, bound: u32) -> (f64, f64) { + let mut direction = if delta < 0.0 { -1.0 } else { 1.0 }; + let mut room = if direction > 0.0 { + f64::from(bound) - anchor + } else { + anchor + }; + if room == 0.0 { + direction = -direction; + room = if direction > 0.0 { + f64::from(bound) - anchor + } else { + anchor + }; + } + (direction, room) +} + +/// A quantized pixel span. It may be empty while a selection is in progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PixelSpan { + x: u32, + y: u32, + width: u32, + height: u32, +} + +impl PixelSpan { + pub const fn x(self) -> u32 { + self.x + } + + pub const fn y(self) -> u32 { + self.y + } + + pub const fn width(self) -> u32 { + self.width + } + + pub const fn height(self) -> u32 { + self.height + } + + pub const fn size(self) -> (u32, u32) { + (self.width, self.height) + } +} + +/// Quantize two unordered image edges using floor-minimum/ceil-maximum coverage. +pub fn pixel_span(first: ImagePoint, second: ImagePoint, bounds: (u32, u32)) -> Option { + let first = clamp_edge(first, bounds)?; + let second = clamp_edge(second, bounds)?; + let left = first.x.min(second.x).floor() as u32; + let top = first.y.min(second.y).floor() as u32; + let right = first.x.max(second.x).ceil() as u32; + let bottom = first.y.max(second.y).ceil() as u32; + Some(PixelSpan { + x: left, + y: top, + width: right - left, + height: bottom - top, + }) +} + +/// A non-empty rectangle of source-image pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImagePixelRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +impl ImagePixelRect { + /// Construct a non-empty rectangle fully contained by `bounds`. + pub fn new(x: u32, y: u32, width: u32, height: u32, bounds: (u32, u32)) -> Option { + if width == 0 + || height == 0 + || x.checked_add(width)? > bounds.0 + || y.checked_add(height)? > bounds.1 + { + return None; + } + Some(Self { + x, + y, + width, + height, + }) + } + + pub const fn size(self) -> (u32, u32) { + (self.width, self.height) + } + + pub const fn x(self) -> u32 { + self.x + } + + pub const fn y(self) -> u32 { + self.y + } + + pub const fn width(self) -> u32 { + self.width + } + + pub const fn height(self) -> u32 { + self.height + } + + /// Select every pixel in non-empty image bounds. + pub fn whole(bounds: (u32, u32)) -> Option { + Self::new(0, 0, bounds.0, bounds.1, bounds) + } + + /// Quantize two unordered image edges into a non-empty in-bounds rectangle. + pub fn from_points(first: ImagePoint, second: ImagePoint, bounds: (u32, u32)) -> Option { + pixel_span(first, second, bounds)?.try_into().ok() + } + + /// Translate the rectangle and clamp its origin while preserving its size. + pub fn translated_clamped( + self, + delta_x: i64, + delta_y: i64, + bounds: (u32, u32), + ) -> Option { + let max_x = bounds.0.checked_sub(self.width)?; + let max_y = bounds.1.checked_sub(self.height)?; + let x = (i128::from(self.x) + i128::from(delta_x)).clamp(0, i128::from(max_x)) as u32; + let y = (i128::from(self.y) + i128::from(delta_y)).clamp(0, i128::from(max_y)) as u32; + Self::new(x, y, self.width, self.height, bounds) + } +} + +impl TryFrom for ImagePixelRect { + type Error = (); + + fn try_from(span: PixelSpan) -> Result { + if span.width == 0 || span.height == 0 { + return Err(()); + } + Ok(Self { + x: span.x, + y: span.y, + width: span.width, + height: span.height, + }) + } +} + +/// Tightly packed premultiplied ARGB32 pixels in Cairo's native byte order. +#[derive(Clone, PartialEq, Eq)] +pub struct PackedArgb32 { + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) stride: i32, + pub(crate) data: Vec, +} + +impl PackedArgb32 { + /// Construct a buffer whose stride and byte length exactly match its dimensions. + pub fn new(width: u32, height: u32, stride: i32, data: Vec) -> Option { + let row_bytes = width.checked_mul(4)?; + if stride != i32::try_from(row_bytes).ok()? { + return None; + } + let byte_len = usize::try_from(row_bytes) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + if data.len() != byte_len { + return None; + } + Some(Self { + width, + height, + stride, + data, + }) + } + + pub const fn width(&self) -> u32 { + self.width + } + + pub const fn height(&self) -> u32 { + self.height + } + + pub const fn stride(&self) -> i32 { + self.stride + } + + pub fn data(&self) -> &[u8] { + &self.data + } +} + +impl std::fmt::Debug for PackedArgb32 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PackedArgb32") + .field("width", &self.width) + .field("height", &self.height) + .field("stride", &self.stride) + .field("bytes", &self.data.len()) + .finish() + } +} + +/// Resource limits shared by persisted embedded images and clipboard images. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbeddedImageLimits { + max_bytes: usize, + max_pixels: u64, +} + +impl EmbeddedImageLimits { + const DEFAULT_MAX_BYTES: usize = 3 * 1024 * 1024; + const DEFAULT_MAX_PIXELS: u64 = 48_000_000; + + pub const fn max_bytes(self) -> usize { + self.max_bytes + } + + pub const fn max_pixels(self) -> u64 { + self.max_pixels + } + + pub const fn allows_bytes(self, byte_len: usize) -> bool { + byte_len <= self.max_bytes + } + + pub fn allows_pixels(self, width: u32, height: u32) -> bool { + u64::from(width) + .checked_mul(u64::from(height)) + .is_some_and(|pixels| pixels <= self.max_pixels) + } +} + +impl Default for EmbeddedImageLimits { + fn default() -> Self { + Self { + max_bytes: Self::DEFAULT_MAX_BYTES, + max_pixels: Self::DEFAULT_MAX_PIXELS, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_image_limits_keep_the_existing_clipboard_boundaries() { + let limits = EmbeddedImageLimits::default(); + + assert_eq!(limits.max_bytes(), 3 * 1024 * 1024); + assert_eq!(limits.max_pixels(), 48_000_000); + assert!(limits.allows_bytes(3 * 1024 * 1024)); + assert!(!limits.allows_bytes(3 * 1024 * 1024 + 1)); + assert!(limits.allows_pixels(8_000, 6_000)); + assert!(!limits.allows_pixels(8_000, 6_001)); + } + + #[test] + fn image_pixel_rect_constructor_requires_a_non_empty_in_bounds_rectangle() { + assert_eq!( + ImagePixelRect::new(2, 3, 4, 5, (10, 10)).map(ImagePixelRect::size), + Some((4, 5)) + ); + assert_eq!(ImagePixelRect::new(2, 3, 0, 5, (10, 10)), None); + assert_eq!(ImagePixelRect::new(2, 3, 4, 0, (10, 10)), None); + assert_eq!(ImagePixelRect::new(8, 3, 4, 5, (10, 10)), None); + assert_eq!(ImagePixelRect::new(2, 8, 4, 5, (10, 10)), None); + assert_eq!(ImagePixelRect::new(u32::MAX, 0, 2, 1, (10, 10)), None); + } + + #[test] + fn whole_and_translation_preserve_the_non_empty_in_bounds_invariant() { + let whole = ImagePixelRect::whole((1, 4)).expect("non-zero bounds"); + assert_eq!((whole.x(), whole.y(), whole.size()), (0, 0, (1, 4))); + assert_eq!(ImagePixelRect::whole((0, 4)), None); + assert_eq!(ImagePixelRect::whole((4, 0)), None); + + let rect = ImagePixelRect::new(2, 3, 4, 2, (10, 10)).unwrap(); + let lower_right = rect.translated_clamped(20, 20, (10, 10)).unwrap(); + assert_eq!( + (lower_right.x(), lower_right.y(), lower_right.size()), + (6, 8, (4, 2)) + ); + let upper_left = rect + .translated_clamped(i64::MIN, i64::MIN, (10, 10)) + .unwrap(); + assert_eq!((upper_left.x(), upper_left.y()), (0, 0)); + assert_eq!(rect.translated_clamped(0, 0, (3, 10)), None); + } + + #[test] + fn anchor_snapping_floors_and_clamps_to_existing_pixel_indices() { + assert_eq!( + snap_anchor(ImagePoint::new(2.9, 3.1), (5, 6)), + Some(ImagePoint::new(2.0, 3.0)) + ); + assert_eq!( + snap_anchor(ImagePoint::new(-4.0, 99.0), (5, 6)), + Some(ImagePoint::new(0.0, 5.0)) + ); + assert_eq!( + snap_anchor(ImagePoint::new(99.0, 2.8), (1, 5)), + Some(ImagePoint::new(0.0, 2.0)) + ); + assert_eq!( + snap_anchor(ImagePoint::new(4.0, 8.0), (1, 1)), + Some(ImagePoint::new(0.0, 0.0)) + ); + } + + #[test] + fn edge_clamping_keeps_the_far_pixel_edge_inclusive() { + assert_eq!( + clamp_edge(ImagePoint::new(-0.2, 8.0), (5, 6)), + Some(ImagePoint::new(0.0, 6.0)) + ); + assert_eq!( + clamp_edge(ImagePoint::new(1.0, 5.0), (1, 5)), + Some(ImagePoint::new(1.0, 5.0)) + ); + assert_eq!( + clamp_edge(ImagePoint::new(0.5, 0.75), (1, 1)), + Some(ImagePoint::new(0.5, 0.75)) + ); + } + + #[test] + fn point_mapping_rejects_non_finite_coordinates_and_zero_bounds() { + for point in [ + ImagePoint::new(f64::NAN, 0.0), + ImagePoint::new(0.0, f64::NAN), + ImagePoint::new(f64::INFINITY, 0.0), + ImagePoint::new(0.0, f64::NEG_INFINITY), + ] { + assert_eq!(snap_anchor(point, (5, 5)), None); + assert_eq!(clamp_edge(point, (5, 5)), None); + } + for bounds in [(0, 0), (0, 5), (5, 0)] { + assert_eq!(snap_anchor(ImagePoint::new(1.0, 1.0), bounds), None); + assert_eq!(clamp_edge(ImagePoint::new(1.0, 1.0), bounds), None); + } + } + + #[test] + fn square_cursor_uses_the_dominant_axis_and_preserves_direction() { + assert_eq!( + squared_cursor( + ImagePoint::new(4.0, 4.0), + ImagePoint::new(8.0, 6.0), + (10, 10) + ), + Some(ImagePoint::new(8.0, 8.0)) + ); + assert_eq!( + squared_cursor( + ImagePoint::new(4.0, 4.0), + ImagePoint::new(2.0, 9.0), + (10, 10) + ), + Some(ImagePoint::new(0.0, 8.0)) + ); + assert_eq!( + squared_cursor( + ImagePoint::new(6.0, 6.0), + ImagePoint::new(1.0, 2.0), + (10, 10) + ), + Some(ImagePoint::new(1.0, 1.0)) + ); + } + + #[test] + fn square_cursor_treats_zero_as_positive_and_flips_a_blocked_edge() { + assert_eq!( + squared_cursor( + ImagePoint::new(2.0, 2.0), + ImagePoint::new(2.0, 5.0), + (10, 10) + ), + Some(ImagePoint::new(5.0, 5.0)) + ); + assert_eq!( + squared_cursor( + ImagePoint::new(2.0, 2.0), + ImagePoint::new(5.0, 2.0), + (10, 10) + ), + Some(ImagePoint::new(5.0, 5.0)) + ); + assert_eq!( + squared_cursor( + ImagePoint::new(0.0, 2.0), + ImagePoint::new(-3.0, 5.0), + (5, 5) + ), + Some(ImagePoint::new(3.0, 5.0)) + ); + assert_eq!( + squared_cursor(ImagePoint::new(2.0, 2.0), ImagePoint::new(2.0, 2.0), (5, 5)), + Some(ImagePoint::new(2.0, 2.0)) + ); + } + + #[test] + fn square_cursor_selects_the_only_pixel_and_caps_one_pixel_wide_images() { + assert_eq!( + squared_cursor(ImagePoint::new(0.0, 0.0), ImagePoint::new(9.0, 9.0), (1, 1)), + Some(ImagePoint::new(1.0, 1.0)) + ); + assert_eq!( + squared_cursor(ImagePoint::new(0.0, 2.0), ImagePoint::new(0.0, 8.0), (1, 9)), + Some(ImagePoint::new(1.0, 3.0)) + ); + } + + #[test] + fn square_cursor_rejects_invalid_inputs_and_caps_finite_extremes() { + assert_eq!( + squared_cursor( + ImagePoint::new(-1.0, 0.0), + ImagePoint::new(1.0, 1.0), + (5, 5) + ), + None + ); + assert_eq!( + squared_cursor( + ImagePoint::new(0.0, 0.0), + ImagePoint::new(f64::INFINITY, 1.0), + (5, 5) + ), + None + ); + assert_eq!( + squared_cursor(ImagePoint::new(0.0, 0.0), ImagePoint::new(1.0, 1.0), (0, 5)), + None + ); + assert_eq!( + squared_cursor( + ImagePoint::new(0.0, 0.0), + ImagePoint::new(f64::MAX, -f64::MAX), + (5, 5) + ), + Some(ImagePoint::new(5.0, 5.0)) + ); + } + + #[test] + fn pixel_span_floors_minimums_ceils_maximums_and_supports_reversed_drags() { + let forward = + pixel_span(ImagePoint::new(0.2, 1.8), ImagePoint::new(3.1, 4.0), (8, 8)).unwrap(); + let reversed = + pixel_span(ImagePoint::new(3.1, 4.0), ImagePoint::new(0.2, 1.8), (8, 8)).unwrap(); + assert_eq!(forward, reversed); + assert_eq!((forward.x(), forward.y(), forward.size()), (0, 1, (4, 3))); + assert_eq!( + ImagePixelRect::try_from(forward).map(ImagePixelRect::size), + Ok((4, 3)) + ); + assert_eq!( + ImagePixelRect::from_points( + ImagePoint::new(3.1, 4.0), + ImagePoint::new(0.2, 1.8), + (8, 8) + ), + ImagePixelRect::new(0, 1, 4, 3, (8, 8)) + ); + } + + #[test] + fn pixel_span_clamps_to_bounds_and_can_represent_an_empty_drag() { + let clipped = pixel_span( + ImagePoint::new(-3.5, 2.2), + ImagePoint::new(12.0, 99.0), + (5, 6), + ) + .unwrap(); + assert_eq!((clipped.x(), clipped.y(), clipped.size()), (0, 2, (5, 4))); + + let empty = + pixel_span(ImagePoint::new(2.0, 3.0), ImagePoint::new(2.0, 3.0), (5, 6)).unwrap(); + assert_eq!((empty.x(), empty.y(), empty.size()), (2, 3, (0, 0))); + assert_eq!(ImagePixelRect::try_from(empty), Err(())); + + let zero_width = + pixel_span(ImagePoint::new(2.0, 1.0), ImagePoint::new(2.0, 4.0), (5, 6)).unwrap(); + assert_eq!(zero_width.size(), (0, 3)); + assert_eq!(ImagePixelRect::try_from(zero_width), Err(())); + } + + #[test] + fn pixel_span_covers_one_pixel_and_one_pixel_wide_images() { + let sole_pixel = + pixel_span(ImagePoint::new(0.0, 0.0), ImagePoint::new(1.0, 1.0), (1, 1)).unwrap(); + assert_eq!(sole_pixel.size(), (1, 1)); + assert_eq!( + ImagePixelRect::try_from(sole_pixel), + ImagePixelRect::whole((1, 1)).ok_or(()) + ); + + let vertical = + pixel_span(ImagePoint::new(1.0, 8.8), ImagePoint::new(0.0, 1.2), (1, 9)).unwrap(); + assert_eq!( + (vertical.x(), vertical.y(), vertical.size()), + (0, 1, (1, 8)) + ); + } + + #[test] + fn pixel_span_rejects_non_finite_points_and_zero_bounds() { + for point in [ + ImagePoint::new(f64::NAN, 1.0), + ImagePoint::new(1.0, f64::INFINITY), + ] { + assert_eq!(pixel_span(ImagePoint::new(0.0, 0.0), point, (5, 5)), None); + assert_eq!(pixel_span(point, ImagePoint::new(0.0, 0.0), (5, 5)), None); + assert_eq!(ImagePixelRect::from_points(point, point, (5, 5)), None); + } + for bounds in [(0, 0), (0, 5), (5, 0)] { + assert_eq!( + pixel_span(ImagePoint::new(0.0, 0.0), ImagePoint::new(1.0, 1.0), bounds), + None + ); + assert_eq!( + ImagePixelRect::from_points( + ImagePoint::new(0.0, 0.0), + ImagePoint::new(1.0, 1.0), + bounds + ), + None + ); + } + } + + #[test] + fn packed_argb32_accepts_only_exact_tightly_packed_storage() { + let pixels = PackedArgb32::new(2, 3, 8, vec![0xA5; 24]).expect("valid packed pixels"); + assert_eq!( + (pixels.width(), pixels.height(), pixels.stride()), + (2, 3, 8) + ); + assert_eq!(pixels.data(), &[0xA5; 24]); + assert_eq!(PackedArgb32::new(2, 3, 12, vec![0; 36]), None); + assert_eq!(PackedArgb32::new(2, 3, 8, vec![0; 23]), None); + assert_eq!(PackedArgb32::new(u32::MAX, 1, -4, Vec::new()), None); + } + + #[test] + fn packed_argb32_debug_redacts_captured_bytes() { + let pixels = PackedArgb32::new(3, 4, 12, vec![0xFF; 48]).unwrap(); + let rendered = format!("{pixels:?}"); + assert!(rendered.contains("width: 3")); + assert!(rendered.contains("bytes: 48")); + assert!(!rendered.contains("255")); + } +} diff --git a/src/ui.rs b/src/ui.rs index 1bb19c6ae..404933ba7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,6 +6,7 @@ mod color_picker_popup; mod command_palette; pub mod constants; mod context_menu; +mod eyedropper_loupe; mod help_overlay; mod input_hud; mod onboarding_card; @@ -23,6 +24,7 @@ pub use board_picker::render_board_picker; pub use color_picker_popup::{color_picker_popup_visual_geometry, render_color_picker_popup}; pub use command_palette::{command_palette_visual_geometry, render_command_palette}; pub use context_menu::render_context_menu; +pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedropper_loupe}; #[allow(unused_imports)] pub use help_overlay::HelpOverlayBindings; #[cfg(test)] diff --git a/src/ui/eyedropper_loupe.rs b/src/ui/eyedropper_loupe.rs new file mode 100644 index 000000000..3daa16811 --- /dev/null +++ b/src/ui/eyedropper_loupe.rs @@ -0,0 +1,165 @@ +use crate::draw::Color; + +const PIXELS: i32 = 11; +const CELL: f64 = 8.0; +const GAP: f64 = 18.0; +const LABEL_HEIGHT: f64 = 24.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct EyedropperLoupeLayout { + pub panel_x: f64, + pub panel_y: f64, + pub panel_width: f64, + pub panel_height: f64, + pub grid_x: f64, + pub grid_y: f64, + pub grid_size: f64, +} + +pub(crate) fn compute_eyedropper_loupe_layout( + pointer: (f64, f64), + screen: (u32, u32), +) -> EyedropperLoupeLayout { + let grid_size = f64::from(PIXELS) * CELL; + let panel_width = grid_size + 12.0; + let panel_height = grid_size + LABEL_HEIGHT + 12.0; + let mut panel_x = pointer.0 + GAP; + let mut panel_y = pointer.1 + GAP; + if panel_x + panel_width > f64::from(screen.0) { + panel_x = pointer.0 - GAP - panel_width; + } + if panel_y + panel_height > f64::from(screen.1) { + panel_y = pointer.1 - GAP - panel_height; + } + panel_x = panel_x.clamp(4.0, (f64::from(screen.0) - panel_width - 4.0).max(4.0)); + panel_y = panel_y.clamp(4.0, (f64::from(screen.1) - panel_height - 4.0).max(4.0)); + EyedropperLoupeLayout { + panel_x, + panel_y, + panel_width, + panel_height, + grid_x: panel_x + 6.0, + grid_y: panel_y + 6.0, + grid_size, + } +} + +pub(crate) fn render_eyedropper_loupe( + ctx: &cairo::Context, + layout: EyedropperLoupeLayout, + image_center: (f64, f64), + mut sample: impl FnMut(f64, f64) -> Option, +) { + let _ = ctx.save(); + ctx.set_source_rgba(0.04, 0.05, 0.07, 0.96); + ctx.rectangle( + layout.panel_x, + layout.panel_y, + layout.panel_width, + layout.panel_height, + ); + let _ = ctx.fill(); + + let center_x = image_center.0.floor(); + let center_y = image_center.1.floor(); + let half = PIXELS / 2; + for row in 0..PIXELS { + for col in 0..PIXELS { + if let Some(color) = sample( + center_x + f64::from(col - half), + center_y + f64::from(row - half), + ) { + ctx.set_source_rgb(color.r, color.g, color.b); + ctx.rectangle( + layout.grid_x + f64::from(col) * CELL, + layout.grid_y + f64::from(row) * CELL, + CELL, + CELL, + ); + let _ = ctx.fill(); + } + } + } + + let center = f64::from(half) * CELL; + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.set_line_width(2.0); + ctx.rectangle(layout.grid_x + center, layout.grid_y + center, CELL, CELL); + let _ = ctx.stroke(); + ctx.set_source_rgb(0.0, 0.0, 0.0); + ctx.set_line_width(1.0); + ctx.rectangle( + layout.grid_x + center + 2.0, + layout.grid_y + center + 2.0, + CELL - 4.0, + CELL - 4.0, + ); + let _ = ctx.stroke(); + + if let Some(color) = sample(center_x, center_y) { + let hex = format!( + "#{:02X}{:02X}{:02X}", + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8 + ); + let swatch = 14.0; + let label_y = layout.grid_y + layout.grid_size + 5.0; + ctx.set_source_rgb(color.r, color.g, color.b); + ctx.rectangle(layout.grid_x, label_y, swatch, swatch); + let _ = ctx.fill(); + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(12.0); + ctx.move_to(layout.grid_x + swatch + 7.0, label_y + 12.0); + let _ = ctx.show_text(&hex); + } + + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.65); + ctx.set_line_width(1.0); + ctx.rectangle( + layout.panel_x + 0.5, + layout.panel_y + 0.5, + layout.panel_width - 1.0, + layout.panel_height - 1.0, + ); + let _ = ctx.stroke(); + let _ = ctx.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_prefers_below_right_then_flips_and_clamps() { + assert_eq!( + compute_eyedropper_loupe_layout((20.0, 30.0), (400, 400)), + EyedropperLoupeLayout { + panel_x: 38.0, + panel_y: 48.0, + panel_width: 100.0, + panel_height: 124.0, + grid_x: 44.0, + grid_y: 54.0, + grid_size: 88.0, + } + ); + assert_eq!( + compute_eyedropper_loupe_layout((390.0, 390.0), (400, 400)), + EyedropperLoupeLayout { + panel_x: 272.0, + panel_y: 248.0, + panel_width: 100.0, + panel_height: 124.0, + grid_x: 278.0, + grid_y: 254.0, + grid_size: 88.0, + } + ); + assert_eq!( + compute_eyedropper_loupe_layout((40.0, 40.0), (80, 80)).panel_x, + 4.0 + ); + } +} From a51696d6cbf3ffa1467538d655dac55b9b192118 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:09:09 +0200 Subject: [PATCH 2/3] feat(capture): harden screen-region source identity --- README.md | 3 + docs/CONFIG.md | 3 + .../wayland/backend/event_loop/capture.rs | 1 + src/backend/wayland/frozen/state.rs | 8 + src/backend/wayland/handlers/compositor.rs | 8 +- src/backend/wayland/handlers/keyboard/mod.rs | 22 +- src/backend/wayland/handlers/layer.rs | 6 +- src/backend/wayland/handlers/output.rs | 3 + src/backend/wayland/handlers/xdg.rs | 3 +- src/backend/wayland/state/acquisition.rs | 1005 +---------------- .../wayland/state/acquisition/transaction.rs | 357 ++++++ .../state/acquisition/transaction/tests.rs | 720 ++++++++++++ src/backend/wayland/state/capture.rs | 7 + src/backend/wayland/state/data.rs | 3 + src/backend/wayland/state/eyedropper.rs | 51 +- src/backend/wayland/state/ocr.rs | 68 +- src/backend/wayland/state/region_capture.rs | 125 +- src/backend/wayland/state/screen_image.rs | 36 +- src/backend/wayland/state/zoom.rs | 6 + src/backend/wayland/zoom/state.rs | 8 + .../state/actions/action_capture_zoom.rs | 70 ++ 21 files changed, 1462 insertions(+), 1051 deletions(-) create mode 100644 src/backend/wayland/state/acquisition/transaction.rs create mode 100644 src/backend/wayland/state/acquisition/transaction/tests.rs diff --git a/README.md b/README.md index 6a4671957..760375b45 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,9 @@ it to the clipboard. It is optional: the action has no default shortcut and its toolbar button is hidden until you turn it on. Install Tesseract and the language data you configure in `[capture].ocr_languages` (default `eng`): +An active OCR selection cancels if the displayed screen image changes, so the +selected coordinates can never be applied to replacement freeze or zoom pixels. + ```bash sudo pacman -S tesseract tesseract-data-eng # Arch/Omarchy sudo apt-get install tesseract-ocr # Debian/Ubuntu diff --git a/docs/CONFIG.md b/docs/CONFIG.md index afc780d98..241a2d488 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1427,6 +1427,9 @@ does not change the active tool, the drawing history, or the board. does nothing. - On a solid whiteboard or blackboard with no visible screen capture, OCR refuses rather than reading the board. +- An active OCR selection cancels if the displayed screen image changes, the + zoom level or pan changes, or freeze, output, scale, or display layout state + is replaced. **Requirements:** the `tesseract` command plus the language data for every code in `ocr_languages`, and `wl-copy` (from `wl-clipboard`) for the clipboard write. diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index bcef94cd7..6ef2c4ae2 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -238,6 +238,7 @@ fn handle_frozen_toggle(state: &mut WaylandState) { FrozenUserToggleAction::Unfreeze => { state.restore_xdg_after_frozen(); state.frozen.unfreeze(&mut state.input_state); + state.cancel_screen_modals_if_source_changed(); } FrozenUserToggleAction::ReportUnavailable => { warn!( diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index fd18d49f4..0dfba8dd2 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -227,6 +227,14 @@ impl FrozenState { self.output_layout_generation } + pub(in crate::backend::wayland) fn source_context_matches( + &self, + provenance: ScreenImageProvenance, + ) -> bool { + self.active_output_id == Some(provenance.output_id) + && self.output_layout_generation == provenance.output_layout_generation + } + pub(in crate::backend::wayland) fn image_provenance(&self) -> Option { self.image.as_ref()?; self.image_provenance diff --git a/src/backend/wayland/handlers/compositor.rs b/src/backend/wayland/handlers/compositor.rs index 9cea01d44..8478c11c9 100644 --- a/src/backend/wayland/handlers/compositor.rs +++ b/src/backend/wayland/handlers/compositor.rs @@ -60,8 +60,7 @@ impl CompositorHandler for WaylandState { .handle_resize(phys_w, phys_h, &mut self.input_state); self.zoom .handle_resize(phys_w, phys_h, &mut self.input_state); - self.cancel_eyedropper_if_source_missing(); - self.cancel_ocr_if_source_missing(); + self.cancel_screen_modals_if_source_changed(); self.toolbar .maybe_update_scale(self.surface.current_output().as_ref(), scale); self.toolbar.mark_dirty(); @@ -81,6 +80,7 @@ impl CompositorHandler for WaylandState { debug!("Transform changed"); self.refresh_freeze_zoom_geometry(); + self.cancel_screen_modals_if_source_changed(); } fn frame( @@ -145,8 +145,7 @@ impl CompositorHandler for WaylandState { .handle_resize(phys_w, phys_h, &mut self.input_state); self.zoom .handle_resize(phys_w, phys_h, &mut self.input_state); - self.cancel_eyedropper_if_source_missing(); - self.cancel_ocr_if_source_missing(); + self.cancel_screen_modals_if_source_changed(); // If freeze-on-start was requested, trigger it once the surface is configured and active. if self.pending_freeze_on_start() { @@ -182,5 +181,6 @@ impl CompositorHandler for WaylandState { 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(); } } diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 41cf8743d..e6e0abae0 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -160,19 +160,29 @@ impl KeyboardHandler for WaylandState { // re-arms it at the end of this handler. self.clear_key_repeat(); if self.input_state.region_is_engaged() { + let action = self.input_state.action_for_key(key); + if action.is_some_and(|action| { + self.input_state + .refuse_region_capture_while_screen_modal_engaged(action) + }) { + return; + } // Every other shortcut is swallowed while the selector is up so a // key cannot change the active tool mid-drag. - if matches!(key, Key::Escape) - || self.input_state.action_for_key(key) == Some(Action::CopyTextFromScreen) - { + if matches!(key, Key::Escape) || action == Some(Action::CopyTextFromScreen) { self.cancel_ocr(); } return; } if self.input_state.eyedropper_is_engaged() { - if matches!(key, Key::Escape) - || self.input_state.action_for_key(key) == Some(Action::PickScreenColor) - { + let action = self.input_state.action_for_key(key); + if action.is_some_and(|action| { + self.input_state + .refuse_region_capture_while_screen_modal_engaged(action) + }) { + return; + } + if matches!(key, Key::Escape) || action == Some(Action::PickScreenColor) { self.cancel_eyedropper(); } return; diff --git a/src/backend/wayland/handlers/layer.rs b/src/backend/wayland/handlers/layer.rs index f1e206bf7..954ae237d 100644 --- a/src/backend/wayland/handlers/layer.rs +++ b/src/backend/wayland/handlers/layer.rs @@ -68,8 +68,7 @@ impl LayerShellHandler for WaylandState { .handle_resize(phys_w, phys_h, &mut self.input_state); self.zoom .handle_resize(phys_w, phys_h, &mut self.input_state); - self.cancel_eyedropper_if_source_missing(); - self.cancel_ocr_if_source_missing(); + self.cancel_screen_modals_if_source_changed(); // Refresh active geometry for capture validation using the latest // configured surface size and compositor output metadata. @@ -91,8 +90,7 @@ impl LayerShellHandler for WaylandState { .handle_resize(phys_w, phys_h, &mut self.input_state); self.zoom .handle_resize(phys_w, phys_h, &mut self.input_state); - self.cancel_eyedropper_if_source_missing(); - self.cancel_ocr_if_source_missing(); + self.cancel_screen_modals_if_source_changed(); // Re-apply toolbar offsets now that we have a configured surface size; avoids clamping to 0 // on startup before the compositor provides dimensions. diff --git a/src/backend/wayland/handlers/output.rs b/src/backend/wayland/handlers/output.rs index df5ada9e4..5833aa402 100644 --- a/src/backend/wayland/handlers/output.rs +++ b/src/backend/wayland/handlers/output.rs @@ -19,6 +19,7 @@ impl OutputHandler for WaylandState { debug!("New output detected"); self.refresh_active_output_label(); self.refresh_freeze_zoom_geometry(); + self.cancel_screen_modals_if_source_changed(); } fn update_output( @@ -35,6 +36,7 @@ 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.cancel_screen_modals_if_source_changed(); } fn output_destroyed( @@ -53,5 +55,6 @@ impl OutputHandler for WaylandState { // a walk of current outputs would still include it. Exclude it here; // there is no later callback after the removal. self.refresh_freeze_zoom_geometry_excluding(Some(&output)); + self.cancel_screen_modals_if_source_changed(); } } diff --git a/src/backend/wayland/handlers/xdg.rs b/src/backend/wayland/handlers/xdg.rs index 55fb4d0ce..7bfb3ab8b 100644 --- a/src/backend/wayland/handlers/xdg.rs +++ b/src/backend/wayland/handlers/xdg.rs @@ -120,10 +120,9 @@ impl WindowHandler for WaylandState { .handle_resize(phys_w, phys_h, &mut self.input_state); self.zoom .handle_resize(phys_w, phys_h, &mut self.input_state); - self.cancel_eyedropper_if_source_missing(); - self.cancel_ocr_if_source_missing(); self.refresh_freeze_zoom_geometry(); + self.cancel_screen_modals_if_source_changed(); if self.xdg_frozen_fullscreen_requested() && self.frozen.has_pending_image() { if self.xdg_frozen_fullscreen_pending_configure() && !configure.is_fullscreen() { warn!("xdg frozen fullscreen was not granted; activating freeze on current size"); diff --git a/src/backend/wayland/state/acquisition.rs b/src/backend/wayland/state/acquisition.rs index 1ac2a0323..d568ac233 100644 --- a/src/backend/wayland/state/acquisition.rs +++ b/src/backend/wayland/state/acquisition.rs @@ -1,313 +1,25 @@ +mod transaction; + use crate::backend::wayland::acquisition::{ AcquisitionRecord, AcquisitionStage, ScreenAcquisitionBusy, ScreenAcquisitionCompletion, ScreenAcquisitionId, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, - rejected_ready_generation, }; use crate::backend::wayland::zoom::{ ZoomSourceOutcome, ZoomSourceTerminal, ZoomWaiter, ZoomWaiterOwner, }; -use crate::input::state::{EyedropperCaptureSource, ScreenCaptureSource, Toast, ToastPriority}; +use crate::input::state::{EyedropperCaptureSource, ScreenCaptureSource}; + +use transaction::{ + AcquisitionTransactionRuntime, cancel_modal_owner_resources, report_inconsistent_capture_to, + report_screen_terminal_to, route_acquisition_transaction, +}; +pub(super) use transaction::{ + report_screen_source_activation_rejected_to, report_zoom_terminal_to, +}; -use super::region_capture::owned_generation_is_current; use super::screen_image::displayed_screen_image; use super::{OverlaySuppression, WaylandState}; -const INCONSISTENT_CAPTURE_MESSAGE: &str = "Screen capture state was inconsistent; try again."; - -fn report_inconsistent_capture_to(input_state: &mut crate::input::InputState) { - input_state.push_toast( - ToastPriority::Critical, - "capture", - Toast::error(INCONSISTENT_CAPTURE_MESSAGE), - ); -} - -fn zoom_terminal_report( - owner: Option, - terminal: &ZoomSourceTerminal, -) -> Option<(&'static str, &str)> { - if let Some(report) = terminal.report.as_ref() { - return Some((report.source, report.message.as_str())); - } - let outcome = &terminal.outcome; - if matches!( - outcome, - ZoomSourceOutcome::Ready { .. } - | ZoomSourceOutcome::Cancelled - | ZoomSourceOutcome::StaleLayout - ) { - return None; - } - match owner? { - ZoomWaiterOwner::Eyedropper => Some(("eyedropper", "Screen eyedropper capture failed.")), - ZoomWaiterOwner::Ocr => Some(("ocr", "Screen capture for text recognition failed.")), - ZoomWaiterOwner::RegionCapture => None, - } -} - -pub(super) fn report_zoom_terminal_to( - input_state: &mut crate::input::InputState, - owner: Option, - terminal: &ZoomSourceTerminal, -) { - let Some((source, message)) = zoom_terminal_report(owner, terminal) else { - return; - }; - input_state.push_toast(ToastPriority::Critical, source, Toast::error(message)); -} - -fn report_screen_terminal_to( - input_state: &mut crate::input::InputState, - owner: ScreenAcquisitionOwner, - outcome: &ScreenAcquisitionOutcome, -) { - if owner == ScreenAcquisitionOwner::UserFreeze - && matches!(outcome, ScreenAcquisitionOutcome::Unavailable) - { - input_state.push_toast( - ToastPriority::Info, - "capture", - Toast::warning("Freeze is already preparing another overlay operation."), - ); - return; - } - let message = match outcome { - ScreenAcquisitionOutcome::Ready { .. } | ScreenAcquisitionOutcome::Cancelled => return, - ScreenAcquisitionOutcome::StaleLayout => "Freeze failed after the display layout changed", - ScreenAcquisitionOutcome::Failed(message) => message, - ScreenAcquisitionOutcome::Unavailable => match owner { - ScreenAcquisitionOwner::UserFreeze => unreachable!("handled above"), - ScreenAcquisitionOwner::Eyedropper => "Screen eyedropper capture failed.", - ScreenAcquisitionOwner::Ocr => "Screen capture for text recognition failed.", - ScreenAcquisitionOwner::RegionCapture => return, - }, - }; - let source = match owner { - ScreenAcquisitionOwner::UserFreeze => "freeze", - ScreenAcquisitionOwner::Eyedropper => "eyedropper", - ScreenAcquisitionOwner::Ocr => "ocr", - ScreenAcquisitionOwner::RegionCapture => "capture", - }; - input_state.push_toast( - ToastPriority::Critical, - source, - Toast::error(message.to_string()), - ); -} - -/// Effect boundary for the acquisition transaction. `WaylandState` supplies -/// the live protocol/UI effects; tests supply observable component effects. -/// The routing and fail-closed decisions below are shared production code. -trait AcquisitionTransactionRuntime { - fn acquisition_slot(&self) -> Option; - fn take_acquisition_record(&mut self) -> Option; - fn take_matching_acquisition_record( - &mut self, - id: ScreenAcquisitionId, - owner: ScreenAcquisitionOwner, - ) -> Option; - fn owner_waiter_matches( - &self, - record: &AcquisitionRecord, - completion: &ScreenAcquisitionCompletion, - ) -> bool; - fn input_state(&mut self) -> &mut crate::input::InputState; - fn finish_eyedropper_ready(&mut self, installed_generation: u64); - fn finish_ocr_ready(&mut self, installed_generation: u64); - fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner); - fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner); - fn frozen_generation(&self) -> u64; - fn frozen_active(&self) -> bool; - fn restore_xdg_after_frozen_effect(&mut self); - fn unfreeze_frozen_effect(&mut self); - fn abandon_frozen_effect(&mut self); - fn frozen_completion(&self) -> Option; - fn take_matching_frozen_completion( - &mut self, - id: ScreenAcquisitionId, - owner: ScreenAcquisitionOwner, - ) -> Option; - fn take_frozen_capture_done(&mut self) -> bool; - fn frozen_suppressed(&self) -> bool; - fn end_frozen_suppression(&mut self); -} - -fn release_owned_generation( - runtime: &mut R, - generation: u64, -) -> bool { - if !owned_generation_is_current( - generation, - runtime.frozen_generation(), - runtime.frozen_active(), - ) { - return false; - } - runtime.restore_xdg_after_frozen_effect(); - runtime.unfreeze_frozen_effect(); - true -} - -fn abandon_started_acquisition(runtime: &mut R) { - runtime.restore_xdg_after_frozen_effect(); - runtime.abandon_frozen_effect(); -} - -fn cancel_owner_transition( - runtime: &mut R, - owner: ScreenAcquisitionOwner, -) { - match owner { - ScreenAcquisitionOwner::Eyedropper => { - runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Eyedropper); - } - ScreenAcquisitionOwner::Ocr => { - runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Ocr); - } - ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => {} - } - runtime.cancel_owner_ui(owner); -} - -fn fail_closed_acquisition_transaction( - runtime: &mut R, - record: Option, - completion: ScreenAcquisitionCompletion, -) { - if record.is_some_and(|record| { - record.stage == AcquisitionStage::Started - && (record.id != completion.id || record.owner != completion.owner) - }) { - abandon_started_acquisition(runtime); - } - if runtime.frozen_suppressed() { - runtime.end_frozen_suppression(); - } - if let Some(generation) = rejected_ready_generation( - completion.owner, - &completion.outcome, - runtime.frozen_generation(), - runtime.frozen_active(), - ) { - release_owned_generation(runtime, generation); - } - cancel_owner_transition(runtime, completion.owner); - if let Some(record) = record - && record.owner != completion.owner - { - cancel_owner_transition(runtime, record.owner); - } - report_inconsistent_capture_to(runtime.input_state()); -} - -fn route_acquisition_transaction( - runtime: &mut R, - completion: ScreenAcquisitionCompletion, -) { - let registry_matches = runtime - .acquisition_slot() - .is_some_and(|record| record.id == completion.id && record.owner == completion.owner); - let record = runtime.take_acquisition_record(); - if !registry_matches { - fail_closed_acquisition_transaction(runtime, record, completion); - return; - } - let record = record.expect("a matched screen acquisition record exists"); - if !runtime.owner_waiter_matches(&record, &completion) { - fail_closed_acquisition_transaction(runtime, Some(record), completion); - return; - } - - report_screen_terminal_to(runtime.input_state(), completion.owner, &completion.outcome); - match (completion.owner, completion.outcome) { - (ScreenAcquisitionOwner::UserFreeze, _) => {} - ( - ScreenAcquisitionOwner::Eyedropper, - ScreenAcquisitionOutcome::Ready { - installed_generation, - }, - ) => runtime.finish_eyedropper_ready(installed_generation), - ( - ScreenAcquisitionOwner::Ocr, - ScreenAcquisitionOutcome::Ready { - installed_generation, - }, - ) => runtime.finish_ocr_ready(installed_generation), - (ScreenAcquisitionOwner::Eyedropper | ScreenAcquisitionOwner::Ocr, _) => { - cancel_owner_transition(runtime, completion.owner); - } - (ScreenAcquisitionOwner::RegionCapture, _) => { - // Phase 1 supplies the capture-purpose transition and legacy handoff. - } - } -} - -fn cancel_acquisition_transaction( - runtime: &mut R, - id: ScreenAcquisitionId, - owner: ScreenAcquisitionOwner, -) -> bool { - let Some(record) = runtime - .acquisition_slot() - .filter(|record| record.id == id && record.owner == owner) - else { - log::debug!("Ignoring stale {owner:?} screen acquisition cancellation for {id:?}"); - return false; - }; - - if record.stage == AcquisitionStage::Started - && runtime.frozen_completion().is_some() - && !runtime - .frozen_completion() - .is_some_and(|completion| completion.id == id && completion.owner == owner) - { - log::debug!( - "Retaining mismatched frozen completion for fail-closed routing while cancelling {owner:?} {id:?}" - ); - return false; - } - - let taken = runtime - .take_matching_acquisition_record(id, owner) - .expect("matching acquisition record was just validated"); - if taken.stage == AcquisitionStage::Queued { - return true; - } - - if let Some(completion) = runtime.take_matching_frozen_completion(id, owner) { - report_screen_terminal_to(runtime.input_state(), owner, &completion.outcome); - if owner != ScreenAcquisitionOwner::UserFreeze - && let ScreenAcquisitionOutcome::Ready { - installed_generation, - } = completion.outcome - { - release_owned_generation(runtime, installed_generation); - } - } else if runtime.frozen_completion().is_none() { - abandon_started_acquisition(runtime); - } - let _ = runtime.take_frozen_capture_done(); - if runtime.frozen_suppressed() { - runtime.end_frozen_suppression(); - } - true -} - -fn cancel_modal_owner_resources( - runtime: &mut R, - owner: ScreenAcquisitionOwner, - pending_acquisition: Option, - owned_generation: Option, -) { - if let Some(id) = pending_acquisition { - cancel_acquisition_transaction(runtime, id, owner); - } - if let Some(generation) = owned_generation { - release_owned_generation(runtime, generation); - } - cancel_owner_transition(runtime, owner); -} - impl AcquisitionTransactionRuntime for WaylandState { fn acquisition_slot(&self) -> Option { self.data.screen_acquisition.slot().copied() @@ -337,15 +49,15 @@ impl AcquisitionTransactionRuntime for WaylandState { &mut self.input_state } - fn finish_eyedropper_ready(&mut self, installed_generation: u64) { + fn finish_eyedropper_ready(&mut self, installed_generation: u64) -> bool { self.finish_pending_eyedropper_capture( EyedropperCaptureSource::Frozen, Some(installed_generation), - ); + ) } - fn finish_ocr_ready(&mut self, installed_generation: u64) { - self.finish_pending_ocr_capture(ScreenCaptureSource::Frozen, installed_generation); + fn finish_ocr_ready(&mut self, installed_generation: u64) -> bool { + self.finish_pending_ocr_capture(ScreenCaptureSource::Frozen, installed_generation) } fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { @@ -442,15 +154,27 @@ impl WaylandState { match terminal.outcome { ZoomSourceOutcome::Ready { installed_generation, - } => match waiter.owner { - ZoomWaiterOwner::Eyedropper => { - self.finish_pending_eyedropper_capture(EyedropperCaptureSource::Zoom, None) - } - ZoomWaiterOwner::Ocr => { - self.finish_pending_ocr_capture(ScreenCaptureSource::Zoom, installed_generation) + } => { + let activated = match waiter.owner { + ZoomWaiterOwner::Eyedropper => { + self.finish_pending_eyedropper_capture(EyedropperCaptureSource::Zoom, None) + } + ZoomWaiterOwner::Ocr => self.finish_pending_ocr_capture( + ScreenCaptureSource::Zoom, + installed_generation, + ), + ZoomWaiterOwner::RegionCapture => true, + }; + if !activated { + self.cancel_zoom_owner_ui_only(waiter.owner); + let owner = match waiter.owner { + ZoomWaiterOwner::Eyedropper => ScreenAcquisitionOwner::Eyedropper, + ZoomWaiterOwner::Ocr => ScreenAcquisitionOwner::Ocr, + ZoomWaiterOwner::RegionCapture => ScreenAcquisitionOwner::RegionCapture, + }; + report_screen_source_activation_rejected_to(&mut self.input_state, owner); } - ZoomWaiterOwner::RegionCapture => {} - }, + } _ => self.cancel_zoom_owner_ui_only(waiter.owner), } } @@ -613,6 +337,7 @@ impl WaylandState { } pub(in crate::backend::wayland) fn cancel_eyedropper_ui_only(&mut self) { + self.data.active_eyedropper_source = None; let _ = self.input_state.cancel_eyedropper(); } @@ -640,661 +365,3 @@ impl WaylandState { cancel_modal_owner_resources(self, owner, pending_acquisition, owned_generation); } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; - use crate::input::state::test_support::make_test_input_state; - - struct TransactionRuntime { - registry: ScreenAcquisitionRegistry, - waiter_matches: bool, - input_state: crate::input::InputState, - finished_eyedropper: Vec, - finished_ocr: Vec, - cancelled_owners: Vec, - cleared_zoom_waiters: Vec, - frozen_generation: u64, - frozen_active: bool, - restore_count: usize, - unfreeze_count: usize, - abandon_count: usize, - frozen_completion: Option, - capture_done: bool, - frozen_suppressed: bool, - suppression_end_count: usize, - } - - impl TransactionRuntime { - fn started(owner: ScreenAcquisitionOwner) -> Self { - let mut registry = ScreenAcquisitionRegistry::default(); - let id = registry.request(owner).expect("transaction slot"); - assert!(registry.mark_started(id, owner)); - Self { - registry, - waiter_matches: true, - input_state: make_test_input_state(), - finished_eyedropper: Vec::new(), - finished_ocr: Vec::new(), - cancelled_owners: Vec::new(), - cleared_zoom_waiters: Vec::new(), - frozen_generation: 7, - frozen_active: true, - restore_count: 0, - unfreeze_count: 0, - abandon_count: 0, - frozen_completion: None, - capture_done: false, - frozen_suppressed: true, - suppression_end_count: 0, - } - } - - fn record(&self) -> Option { - self.registry.slot().copied() - } - } - - impl AcquisitionTransactionRuntime for TransactionRuntime { - fn acquisition_slot(&self) -> Option { - self.record() - } - - fn take_acquisition_record(&mut self) -> Option { - self.registry.take() - } - - fn take_matching_acquisition_record( - &mut self, - id: ScreenAcquisitionId, - owner: ScreenAcquisitionOwner, - ) -> Option { - self.registry.take_matching(id, owner) - } - - fn owner_waiter_matches( - &self, - _record: &AcquisitionRecord, - _completion: &ScreenAcquisitionCompletion, - ) -> bool { - self.waiter_matches - } - - fn input_state(&mut self) -> &mut crate::input::InputState { - &mut self.input_state - } - - fn finish_eyedropper_ready(&mut self, installed_generation: u64) { - self.finished_eyedropper.push(installed_generation); - } - - fn finish_ocr_ready(&mut self, installed_generation: u64) { - self.finished_ocr.push(installed_generation); - } - - fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { - self.cancelled_owners.push(owner); - } - - fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner) { - self.cleared_zoom_waiters.push(owner); - } - - fn frozen_generation(&self) -> u64 { - self.frozen_generation - } - - fn frozen_active(&self) -> bool { - self.frozen_active - } - - fn restore_xdg_after_frozen_effect(&mut self) { - self.restore_count += 1; - } - - fn unfreeze_frozen_effect(&mut self) { - self.unfreeze_count += 1; - self.frozen_active = false; - } - - fn abandon_frozen_effect(&mut self) { - self.abandon_count += 1; - self.frozen_active = false; - } - - fn frozen_completion(&self) -> Option { - self.frozen_completion.clone() - } - - fn take_matching_frozen_completion( - &mut self, - id: ScreenAcquisitionId, - owner: ScreenAcquisitionOwner, - ) -> Option { - if !self - .frozen_completion - .as_ref() - .is_some_and(|completion| completion.id == id && completion.owner == owner) - { - return None; - } - self.frozen_completion.take() - } - - fn take_frozen_capture_done(&mut self) -> bool { - std::mem::take(&mut self.capture_done) - } - - fn frozen_suppressed(&self) -> bool { - self.frozen_suppressed - } - - fn end_frozen_suppression(&mut self) { - self.frozen_suppressed = false; - self.suppression_end_count += 1; - } - } - - #[test] - fn zoom_owner_outcome_reporting_matrix_is_typed() { - let outcomes = [ - ( - ZoomSourceOutcome::Ready { - installed_generation: 1, - }, - false, - ), - (ZoomSourceOutcome::Cancelled, false), - (ZoomSourceOutcome::StaleLayout, false), - (ZoomSourceOutcome::Aborted, true), - (ZoomSourceOutcome::Deactivated, true), - (ZoomSourceOutcome::Failed("detail".to_string()), true), - ]; - - for owner in [ZoomWaiterOwner::Eyedropper, ZoomWaiterOwner::Ocr] { - for (outcome, reports) in &outcomes { - assert_eq!( - zoom_terminal_report( - Some(owner), - &ZoomSourceTerminal::for_test(outcome.clone(), None), - ) - .is_some(), - *reports, - "owner={owner:?} outcome={outcome:?}" - ); - } - } - for (outcome, _) in &outcomes { - assert_eq!( - zoom_terminal_report( - Some(ZoomWaiterOwner::RegionCapture), - &ZoomSourceTerminal::for_test(outcome.clone(), None), - ), - None - ); - } - } - - #[test] - fn specific_zoom_failure_report_replaces_the_owner_fallback() { - let terminal = ZoomSourceTerminal::for_test( - ZoomSourceOutcome::Failed("specific backend failure".to_string()), - Some(crate::backend::wayland::zoom::ZoomTerminalReport { - source: "zoom", - message: "specific backend failure".to_string(), - }), - ); - - for owner in [Some(ZoomWaiterOwner::Ocr), None] { - let mut input_state = make_test_input_state(); - - report_zoom_terminal_to(&mut input_state, owner, &terminal); - - assert_eq!(input_state.test_toast_count(), 1); - assert_eq!( - input_state.test_active_toast_message(), - Some("specific backend failure") - ); - } - } - - #[test] - fn zoom_correlation_failure_emits_exactly_one_inconsistency_toast() { - let mut input_state = make_test_input_state(); - - report_inconsistent_capture_to(&mut input_state); - - assert_eq!(input_state.test_toast_count(), 1); - assert_eq!( - input_state.test_active_toast_message(), - Some(INCONSISTENT_CAPTURE_MESSAGE) - ); - } - - #[test] - fn screen_terminal_reporting_is_exactly_once_for_the_full_owner_outcome_matrix() { - for owner in [ - ScreenAcquisitionOwner::UserFreeze, - ScreenAcquisitionOwner::Eyedropper, - ScreenAcquisitionOwner::Ocr, - ScreenAcquisitionOwner::RegionCapture, - ] { - let key = match owner { - ScreenAcquisitionOwner::UserFreeze => "freeze", - ScreenAcquisitionOwner::Eyedropper => "eyedropper", - ScreenAcquisitionOwner::Ocr => "ocr", - ScreenAcquisitionOwner::RegionCapture => "capture", - }; - let unavailable = match owner { - ScreenAcquisitionOwner::UserFreeze => Some(( - "capture", - "Freeze is already preparing another overlay operation.", - )), - ScreenAcquisitionOwner::Eyedropper => { - Some((key, "Screen eyedropper capture failed.")) - } - ScreenAcquisitionOwner::Ocr => { - Some((key, "Screen capture for text recognition failed.")) - } - ScreenAcquisitionOwner::RegionCapture => None, - }; - let cases = [ - ( - ScreenAcquisitionOutcome::Ready { - installed_generation: 7, - }, - None, - ), - (ScreenAcquisitionOutcome::Cancelled, None), - (ScreenAcquisitionOutcome::Unavailable, unavailable), - ( - ScreenAcquisitionOutcome::StaleLayout, - Some((key, "Freeze failed after the display layout changed")), - ), - ( - ScreenAcquisitionOutcome::Failed("specific backend failure".to_string()), - Some((key, "specific backend failure")), - ), - ]; - - for (outcome, expected) in cases { - let mut input_state = make_test_input_state(); - - report_screen_terminal_to(&mut input_state, owner, &outcome); - - assert_eq!( - input_state.test_toast_count(), - usize::from(expected.is_some()), - "owner={owner:?} outcome={outcome:?}" - ); - assert_eq!( - input_state.test_active_toast_message(), - expected.map(|(_, message)| message), - "owner={owner:?} outcome={outcome:?}" - ); - assert_eq!( - input_state.test_active_toast_key(), - expected.map(|(key, _)| key), - "owner={owner:?} outcome={outcome:?}" - ); - } - } - } - - #[test] - fn matched_ready_completions_run_the_owner_production_transactions() { - for owner in [ - ScreenAcquisitionOwner::UserFreeze, - ScreenAcquisitionOwner::Eyedropper, - ScreenAcquisitionOwner::Ocr, - ] { - let mut runtime = TransactionRuntime::started(owner); - if owner == ScreenAcquisitionOwner::Ocr { - runtime.frozen_generation = 99; - } - let id = runtime.record().expect("started record").id; - - route_acquisition_transaction( - &mut runtime, - ScreenAcquisitionCompletion { - id, - owner, - outcome: ScreenAcquisitionOutcome::Ready { - installed_generation: 7, - }, - }, - ); - - assert_eq!(runtime.record(), None, "owner={owner:?}"); - assert_eq!( - runtime.finished_eyedropper, - if owner == ScreenAcquisitionOwner::Eyedropper { - vec![7] - } else { - Vec::new() - }, - "owner={owner:?}" - ); - assert_eq!( - runtime.finished_ocr, - if owner == ScreenAcquisitionOwner::Ocr { - vec![7] - } else { - Vec::new() - }, - "owner={owner:?}" - ); - assert_eq!(runtime.input_state.test_toast_count(), 0); - assert_eq!(runtime.restore_count, 0); - assert_eq!(runtime.unfreeze_count, 0); - } - } - - #[test] - fn matched_nonready_completions_report_then_cancel_the_owner_ui() { - for owner in [ - ScreenAcquisitionOwner::Eyedropper, - ScreenAcquisitionOwner::Ocr, - ] { - for (outcome, expected_toasts) in [ - (ScreenAcquisitionOutcome::Cancelled, 0), - (ScreenAcquisitionOutcome::Unavailable, 1), - (ScreenAcquisitionOutcome::StaleLayout, 1), - ( - ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), - 1, - ), - ] { - let mut runtime = TransactionRuntime::started(owner); - let id = runtime.record().expect("started record").id; - - route_acquisition_transaction( - &mut runtime, - ScreenAcquisitionCompletion { - id, - owner, - outcome: outcome.clone(), - }, - ); - - assert_eq!( - runtime.record(), - None, - "owner={owner:?} outcome={outcome:?}" - ); - assert_eq!( - runtime.cancelled_owners, - vec![owner], - "owner={owner:?} outcome={outcome:?}" - ); - assert_eq!( - runtime.input_state.test_toast_count(), - expected_toasts, - "owner={owner:?} outcome={outcome:?}" - ); - assert_eq!(runtime.restore_count, 0); - assert_eq!(runtime.unfreeze_count, 0); - } - } - } - - #[test] - fn id_owner_waiter_and_empty_slot_mismatches_fail_closed_once() { - let cases = ["id", "owner", "waiter", "empty"]; - for case in cases { - let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let record = runtime.record().expect("started record"); - let (id, owner) = match case { - "id" => { - let mut other = ScreenAcquisitionRegistry::default(); - other - .request(ScreenAcquisitionOwner::Ocr) - .expect("first other id"); - other.take(); - let different = other - .request(ScreenAcquisitionOwner::Ocr) - .expect("different id"); - (different, ScreenAcquisitionOwner::Ocr) - } - "owner" => (record.id, ScreenAcquisitionOwner::Eyedropper), - "waiter" => { - runtime.waiter_matches = false; - (record.id, record.owner) - } - "empty" => { - runtime.registry.take(); - (record.id, record.owner) - } - _ => unreachable!(), - }; - - route_acquisition_transaction( - &mut runtime, - ScreenAcquisitionCompletion { - id, - owner, - outcome: ScreenAcquisitionOutcome::Failed("failure".to_string()), - }, - ); - - assert_eq!(runtime.record(), None, "case={case}"); - assert_eq!(runtime.input_state.test_toast_count(), 1, "case={case}"); - assert_eq!( - runtime.input_state.test_active_toast_message(), - Some(INCONSISTENT_CAPTURE_MESSAGE), - "case={case}" - ); - assert!(!runtime.frozen_suppressed, "case={case}"); - assert_eq!(runtime.suppression_end_count, 1, "case={case}"); - assert_eq!( - runtime.abandon_count, - usize::from(matches!(case, "id" | "owner")), - "case={case}" - ); - } - } - - #[test] - fn activation_terminal_then_same_batch_cancel_releases_once_and_cleans_up() { - let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let id = runtime.record().expect("started record").id; - runtime.frozen_completion = Some(ScreenAcquisitionCompletion { - id, - owner: ScreenAcquisitionOwner::Ocr, - outcome: ScreenAcquisitionOutcome::Ready { - installed_generation: 7, - }, - }); - runtime.capture_done = true; - - assert!(cancel_acquisition_transaction( - &mut runtime, - id, - ScreenAcquisitionOwner::Ocr, - )); - - assert_eq!(runtime.record(), None); - assert_eq!(runtime.frozen_completion, None); - assert!(!runtime.capture_done); - assert!(!runtime.frozen_active); - assert_eq!(runtime.restore_count, 1); - assert_eq!(runtime.unfreeze_count, 1); - assert!(!runtime.frozen_suppressed); - assert_eq!(runtime.suppression_end_count, 1); - assert_eq!(runtime.input_state.test_toast_count(), 0); - - assert!(!cancel_acquisition_transaction( - &mut runtime, - id, - ScreenAcquisitionOwner::Ocr, - )); - assert_eq!(runtime.restore_count, 1); - assert_eq!(runtime.unfreeze_count, 1); - assert_eq!(runtime.suppression_end_count, 1); - } - - #[test] - fn started_and_queued_cancellation_have_exact_resource_postconditions() { - let mut started = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); - let started_id = started.record().expect("started record").id; - started.capture_done = true; - - assert!(cancel_acquisition_transaction( - &mut started, - started_id, - ScreenAcquisitionOwner::Eyedropper, - )); - assert_eq!(started.record(), None); - assert_eq!(started.restore_count, 1); - assert_eq!(started.abandon_count, 1); - assert_eq!(started.unfreeze_count, 0); - assert!(!started.capture_done); - assert!(!started.frozen_suppressed); - assert_eq!(started.suppression_end_count, 1); - assert_eq!(started.input_state.test_toast_count(), 0); - - let mut queued = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let queued_record = queued.registry.take().expect("started record"); - let queued_id = queued - .registry - .request(ScreenAcquisitionOwner::Ocr) - .expect("queued replacement"); - assert_ne!(queued_record.id, queued_id); - queued.frozen_suppressed = false; - - assert!(cancel_acquisition_transaction( - &mut queued, - queued_id, - ScreenAcquisitionOwner::Ocr, - )); - assert_eq!(queued.record(), None); - assert_eq!(queued.restore_count, 0); - assert_eq!(queued.abandon_count, 0); - assert_eq!(queued.unfreeze_count, 0); - assert_eq!(queued.suppression_end_count, 0); - } - - #[test] - fn cancellation_reports_matching_nonready_terminals_without_releasing_pixels() { - for outcome in [ - ScreenAcquisitionOutcome::Cancelled, - ScreenAcquisitionOutcome::Unavailable, - ScreenAcquisitionOutcome::StaleLayout, - ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), - ] { - let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let id = runtime.record().expect("started record").id; - runtime.frozen_completion = Some(ScreenAcquisitionCompletion { - id, - owner: ScreenAcquisitionOwner::Ocr, - outcome: outcome.clone(), - }); - - assert!(cancel_acquisition_transaction( - &mut runtime, - id, - ScreenAcquisitionOwner::Ocr, - )); - - assert_eq!(runtime.frozen_completion, None, "outcome={outcome:?}"); - assert_eq!(runtime.restore_count, 0, "outcome={outcome:?}"); - assert_eq!(runtime.unfreeze_count, 0, "outcome={outcome:?}"); - assert_eq!( - runtime.input_state.test_toast_count(), - usize::from(!matches!(outcome, ScreenAcquisitionOutcome::Cancelled)), - "outcome={outcome:?}" - ); - assert!(!runtime.frozen_suppressed, "outcome={outcome:?}"); - } - } - - #[test] - fn mismatched_terminal_is_retained_for_fail_closed_routing() { - let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let id = runtime.record().expect("started record").id; - let mut other = ScreenAcquisitionRegistry::default(); - other - .request(ScreenAcquisitionOwner::Ocr) - .expect("other first id"); - other.take(); - let other_id = other - .request(ScreenAcquisitionOwner::Ocr) - .expect("different completion id"); - runtime.frozen_completion = Some(ScreenAcquisitionCompletion { - id: other_id, - owner: ScreenAcquisitionOwner::Ocr, - outcome: ScreenAcquisitionOutcome::Failed("other".to_string()), - }); - - assert!(!cancel_acquisition_transaction( - &mut runtime, - id, - ScreenAcquisitionOwner::Ocr, - )); - - assert!(runtime.record().is_some()); - assert!(runtime.frozen_completion.is_some()); - assert!(runtime.frozen_suppressed); - assert_eq!(runtime.restore_count, 0); - assert_eq!(runtime.abandon_count, 0); - assert_eq!(runtime.input_state.test_toast_count(), 0); - } - - #[test] - fn replacement_generation_survives_ready_cancel_and_stale_release() { - let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); - let id = runtime.record().expect("started record").id; - runtime.frozen_generation = 8; - runtime.frozen_completion = Some(ScreenAcquisitionCompletion { - id, - owner: ScreenAcquisitionOwner::Eyedropper, - outcome: ScreenAcquisitionOutcome::Ready { - installed_generation: 7, - }, - }); - - assert!(cancel_acquisition_transaction( - &mut runtime, - id, - ScreenAcquisitionOwner::Eyedropper, - )); - assert!(runtime.frozen_active); - assert_eq!(runtime.frozen_generation, 8); - assert_eq!(runtime.restore_count, 0); - assert_eq!(runtime.unfreeze_count, 0); - - assert!(!release_owned_generation(&mut runtime, 7)); - assert!(runtime.frozen_active); - assert_eq!(runtime.restore_count, 0); - assert_eq!(runtime.unfreeze_count, 0); - } - - #[test] - fn armed_owner_cleanup_releases_generation_clears_waiter_and_cancels_ui_once() { - for (owner, zoom_owner) in [ - ( - ScreenAcquisitionOwner::Eyedropper, - ZoomWaiterOwner::Eyedropper, - ), - (ScreenAcquisitionOwner::Ocr, ZoomWaiterOwner::Ocr), - ] { - let mut runtime = TransactionRuntime::started(owner); - runtime.registry.take(); - - cancel_modal_owner_resources(&mut runtime, owner, None, Some(7)); - - assert!(!runtime.frozen_active, "owner={owner:?}"); - assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); - assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); - assert_eq!(runtime.cleared_zoom_waiters, vec![zoom_owner]); - assert_eq!(runtime.cancelled_owners, vec![owner]); - - cancel_modal_owner_resources(&mut runtime, owner, None, None); - assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); - assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); - assert_eq!(runtime.cleared_zoom_waiters, vec![zoom_owner, zoom_owner]); - assert_eq!(runtime.cancelled_owners, vec![owner, owner]); - } - } -} diff --git a/src/backend/wayland/state/acquisition/transaction.rs b/src/backend/wayland/state/acquisition/transaction.rs new file mode 100644 index 000000000..22e81bb05 --- /dev/null +++ b/src/backend/wayland/state/acquisition/transaction.rs @@ -0,0 +1,357 @@ +use crate::backend::wayland::acquisition::{ + AcquisitionRecord, AcquisitionStage, ScreenAcquisitionCompletion, ScreenAcquisitionId, + ScreenAcquisitionOutcome, ScreenAcquisitionOwner, rejected_ready_generation, +}; +use crate::backend::wayland::zoom::{ZoomSourceOutcome, ZoomSourceTerminal, ZoomWaiterOwner}; +#[cfg(test)] +use crate::input::state::{EyedropperCaptureSource, ScreenCaptureSource}; +use crate::input::state::{Toast, ToastPriority}; + +use super::super::region_capture::owned_generation_is_current; + +const INCONSISTENT_CAPTURE_MESSAGE: &str = "Screen capture state was inconsistent; try again."; +const SOURCE_ACTIVATION_REJECTED_MESSAGE: &str = + "Screen image changed before the selection could start; try again."; + +pub(super) fn report_inconsistent_capture_to(input_state: &mut crate::input::InputState) { + input_state.push_toast( + ToastPriority::Critical, + "capture", + Toast::error(INCONSISTENT_CAPTURE_MESSAGE), + ); +} + +pub(in crate::backend::wayland::state) fn report_screen_source_activation_rejected_to( + input_state: &mut crate::input::InputState, + owner: ScreenAcquisitionOwner, +) { + let source = match owner { + ScreenAcquisitionOwner::Eyedropper => "eyedropper", + ScreenAcquisitionOwner::Ocr => "ocr", + ScreenAcquisitionOwner::RegionCapture => "capture", + ScreenAcquisitionOwner::UserFreeze => { + debug_assert!(false, "user freeze has no modal selector activation"); + "capture" + } + }; + input_state.push_toast( + ToastPriority::Info, + source, + Toast::warning(SOURCE_ACTIVATION_REJECTED_MESSAGE), + ); +} + +fn zoom_terminal_report( + owner: Option, + terminal: &ZoomSourceTerminal, +) -> Option<(&'static str, &str)> { + if let Some(report) = terminal.report.as_ref() { + return Some((report.source, report.message.as_str())); + } + let outcome = &terminal.outcome; + if matches!( + outcome, + ZoomSourceOutcome::Ready { .. } + | ZoomSourceOutcome::Cancelled + | ZoomSourceOutcome::StaleLayout + ) { + return None; + } + match owner? { + ZoomWaiterOwner::Eyedropper => Some(("eyedropper", "Screen eyedropper capture failed.")), + ZoomWaiterOwner::Ocr => Some(("ocr", "Screen capture for text recognition failed.")), + ZoomWaiterOwner::RegionCapture => None, + } +} + +pub(in crate::backend::wayland::state) fn report_zoom_terminal_to( + input_state: &mut crate::input::InputState, + owner: Option, + terminal: &ZoomSourceTerminal, +) { + let Some((source, message)) = zoom_terminal_report(owner, terminal) else { + return; + }; + input_state.push_toast(ToastPriority::Critical, source, Toast::error(message)); +} + +pub(super) fn report_screen_terminal_to( + input_state: &mut crate::input::InputState, + owner: ScreenAcquisitionOwner, + outcome: &ScreenAcquisitionOutcome, +) { + if owner == ScreenAcquisitionOwner::UserFreeze + && matches!(outcome, ScreenAcquisitionOutcome::Unavailable) + { + input_state.push_toast( + ToastPriority::Info, + "capture", + Toast::warning("Freeze is already preparing another overlay operation."), + ); + return; + } + let message = match outcome { + ScreenAcquisitionOutcome::Ready { .. } | ScreenAcquisitionOutcome::Cancelled => return, + ScreenAcquisitionOutcome::StaleLayout => "Freeze failed after the display layout changed", + ScreenAcquisitionOutcome::Failed(message) => message, + ScreenAcquisitionOutcome::Unavailable => match owner { + ScreenAcquisitionOwner::UserFreeze => unreachable!("handled above"), + ScreenAcquisitionOwner::Eyedropper => "Screen eyedropper capture failed.", + ScreenAcquisitionOwner::Ocr => "Screen capture for text recognition failed.", + ScreenAcquisitionOwner::RegionCapture => return, + }, + }; + let source = match owner { + ScreenAcquisitionOwner::UserFreeze => "freeze", + ScreenAcquisitionOwner::Eyedropper => "eyedropper", + ScreenAcquisitionOwner::Ocr => "ocr", + ScreenAcquisitionOwner::RegionCapture => "capture", + }; + input_state.push_toast( + ToastPriority::Critical, + source, + Toast::error(message.to_string()), + ); +} + +/// Effect boundary for the acquisition transaction. `WaylandState` supplies +/// the live protocol/UI effects; tests supply observable component effects. +/// The routing and fail-closed decisions below are shared production code. +pub(super) trait AcquisitionTransactionRuntime { + fn acquisition_slot(&self) -> Option; + fn take_acquisition_record(&mut self) -> Option; + fn take_matching_acquisition_record( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option; + fn owner_waiter_matches( + &self, + record: &AcquisitionRecord, + completion: &ScreenAcquisitionCompletion, + ) -> bool; + fn input_state(&mut self) -> &mut crate::input::InputState; + fn finish_eyedropper_ready(&mut self, installed_generation: u64) -> bool; + fn finish_ocr_ready(&mut self, installed_generation: u64) -> bool; + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner); + fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner); + fn frozen_generation(&self) -> u64; + fn frozen_active(&self) -> bool; + fn restore_xdg_after_frozen_effect(&mut self); + fn unfreeze_frozen_effect(&mut self); + fn abandon_frozen_effect(&mut self); + fn frozen_completion(&self) -> Option; + fn take_matching_frozen_completion( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option; + fn take_frozen_capture_done(&mut self) -> bool; + fn frozen_suppressed(&self) -> bool; + fn end_frozen_suppression(&mut self); +} + +fn release_owned_generation( + runtime: &mut R, + generation: u64, +) -> bool { + if !owned_generation_is_current( + generation, + runtime.frozen_generation(), + runtime.frozen_active(), + ) { + return false; + } + runtime.restore_xdg_after_frozen_effect(); + runtime.unfreeze_frozen_effect(); + true +} + +fn abandon_started_acquisition(runtime: &mut R) { + runtime.restore_xdg_after_frozen_effect(); + runtime.abandon_frozen_effect(); +} + +fn cancel_owner_transition( + runtime: &mut R, + owner: ScreenAcquisitionOwner, +) { + match owner { + ScreenAcquisitionOwner::Eyedropper => { + runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Eyedropper); + } + ScreenAcquisitionOwner::Ocr => { + runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Ocr); + } + ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => {} + } + runtime.cancel_owner_ui(owner); +} + +fn fail_closed_acquisition_transaction( + runtime: &mut R, + record: Option, + completion: ScreenAcquisitionCompletion, +) { + if record.is_some_and(|record| { + record.stage == AcquisitionStage::Started + && (record.id != completion.id || record.owner != completion.owner) + }) { + abandon_started_acquisition(runtime); + } + if runtime.frozen_suppressed() { + runtime.end_frozen_suppression(); + } + if let Some(generation) = rejected_ready_generation( + completion.owner, + &completion.outcome, + runtime.frozen_generation(), + runtime.frozen_active(), + ) { + release_owned_generation(runtime, generation); + } + cancel_owner_transition(runtime, completion.owner); + if let Some(record) = record + && record.owner != completion.owner + { + cancel_owner_transition(runtime, record.owner); + } + report_inconsistent_capture_to(runtime.input_state()); +} + +pub(super) fn route_acquisition_transaction( + runtime: &mut R, + completion: ScreenAcquisitionCompletion, +) { + let registry_matches = runtime + .acquisition_slot() + .is_some_and(|record| record.id == completion.id && record.owner == completion.owner); + let record = runtime.take_acquisition_record(); + if !registry_matches { + fail_closed_acquisition_transaction(runtime, record, completion); + return; + } + let record = record.expect("a matched screen acquisition record exists"); + if !runtime.owner_waiter_matches(&record, &completion) { + fail_closed_acquisition_transaction(runtime, Some(record), completion); + return; + } + + report_screen_terminal_to(runtime.input_state(), completion.owner, &completion.outcome); + match (completion.owner, completion.outcome) { + (ScreenAcquisitionOwner::UserFreeze, _) => {} + ( + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOutcome::Ready { + installed_generation, + }, + ) => { + if !runtime.finish_eyedropper_ready(installed_generation) { + cancel_modal_owner_resources( + runtime, + ScreenAcquisitionOwner::Eyedropper, + None, + Some(installed_generation), + ); + report_screen_source_activation_rejected_to( + runtime.input_state(), + ScreenAcquisitionOwner::Eyedropper, + ); + } + } + ( + ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOutcome::Ready { + installed_generation, + }, + ) => { + if !runtime.finish_ocr_ready(installed_generation) { + cancel_modal_owner_resources( + runtime, + ScreenAcquisitionOwner::Ocr, + None, + Some(installed_generation), + ); + report_screen_source_activation_rejected_to( + runtime.input_state(), + ScreenAcquisitionOwner::Ocr, + ); + } + } + (ScreenAcquisitionOwner::Eyedropper | ScreenAcquisitionOwner::Ocr, _) => { + cancel_owner_transition(runtime, completion.owner); + } + (ScreenAcquisitionOwner::RegionCapture, _) => { + // Phase 1 supplies the capture-purpose transition and legacy handoff. + } + } +} + +fn cancel_acquisition_transaction( + runtime: &mut R, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, +) -> bool { + let Some(record) = runtime + .acquisition_slot() + .filter(|record| record.id == id && record.owner == owner) + else { + log::debug!("Ignoring stale {owner:?} screen acquisition cancellation for {id:?}"); + return false; + }; + + if record.stage == AcquisitionStage::Started + && runtime.frozen_completion().is_some() + && !runtime + .frozen_completion() + .is_some_and(|completion| completion.id == id && completion.owner == owner) + { + log::debug!( + "Retaining mismatched frozen completion for fail-closed routing while cancelling {owner:?} {id:?}" + ); + return false; + } + + let taken = runtime + .take_matching_acquisition_record(id, owner) + .expect("matching acquisition record was just validated"); + if taken.stage == AcquisitionStage::Queued { + return true; + } + + if let Some(completion) = runtime.take_matching_frozen_completion(id, owner) { + report_screen_terminal_to(runtime.input_state(), owner, &completion.outcome); + if owner != ScreenAcquisitionOwner::UserFreeze + && let ScreenAcquisitionOutcome::Ready { + installed_generation, + } = completion.outcome + { + release_owned_generation(runtime, installed_generation); + } + } else if runtime.frozen_completion().is_none() { + abandon_started_acquisition(runtime); + } + let _ = runtime.take_frozen_capture_done(); + if runtime.frozen_suppressed() { + runtime.end_frozen_suppression(); + } + true +} + +pub(super) fn cancel_modal_owner_resources( + runtime: &mut R, + owner: ScreenAcquisitionOwner, + pending_acquisition: Option, + owned_generation: Option, +) { + if let Some(id) = pending_acquisition { + cancel_acquisition_transaction(runtime, id, owner); + } + if let Some(generation) = owned_generation { + release_owned_generation(runtime, generation); + } + cancel_owner_transition(runtime, owner); +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/state/acquisition/transaction/tests.rs b/src/backend/wayland/state/acquisition/transaction/tests.rs new file mode 100644 index 000000000..fe5eb46b7 --- /dev/null +++ b/src/backend/wayland/state/acquisition/transaction/tests.rs @@ -0,0 +1,720 @@ +use super::*; +use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; +use crate::input::state::test_support::make_test_input_state; + +struct TransactionRuntime { + registry: ScreenAcquisitionRegistry, + waiter_matches: bool, + input_state: crate::input::InputState, + finished_eyedropper: Vec, + finished_ocr: Vec, + ready_activation_succeeds: bool, + cancelled_owners: Vec, + cleared_zoom_waiters: Vec, + frozen_generation: u64, + frozen_active: bool, + restore_count: usize, + unfreeze_count: usize, + abandon_count: usize, + frozen_completion: Option, + capture_done: bool, + frozen_suppressed: bool, + suppression_end_count: usize, +} + +impl TransactionRuntime { + fn started(owner: ScreenAcquisitionOwner) -> Self { + let mut registry = ScreenAcquisitionRegistry::default(); + let id = registry.request(owner).expect("transaction slot"); + assert!(registry.mark_started(id, owner)); + Self { + registry, + waiter_matches: true, + input_state: make_test_input_state(), + finished_eyedropper: Vec::new(), + finished_ocr: Vec::new(), + ready_activation_succeeds: true, + cancelled_owners: Vec::new(), + cleared_zoom_waiters: Vec::new(), + frozen_generation: 7, + frozen_active: true, + restore_count: 0, + unfreeze_count: 0, + abandon_count: 0, + frozen_completion: None, + capture_done: false, + frozen_suppressed: true, + suppression_end_count: 0, + } + } + + fn record(&self) -> Option { + self.registry.slot().copied() + } +} + +impl AcquisitionTransactionRuntime for TransactionRuntime { + fn acquisition_slot(&self) -> Option { + self.record() + } + + fn take_acquisition_record(&mut self) -> Option { + self.registry.take() + } + + fn take_matching_acquisition_record( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + self.registry.take_matching(id, owner) + } + + fn owner_waiter_matches( + &self, + _record: &AcquisitionRecord, + _completion: &ScreenAcquisitionCompletion, + ) -> bool { + self.waiter_matches + } + + fn input_state(&mut self) -> &mut crate::input::InputState { + &mut self.input_state + } + + fn finish_eyedropper_ready(&mut self, installed_generation: u64) -> bool { + if self.ready_activation_succeeds { + self.finished_eyedropper.push(installed_generation); + } + self.ready_activation_succeeds + } + + fn finish_ocr_ready(&mut self, installed_generation: u64) -> bool { + if self.ready_activation_succeeds { + self.finished_ocr.push(installed_generation); + } + self.ready_activation_succeeds + } + + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { + self.cancelled_owners.push(owner); + match owner { + ScreenAcquisitionOwner::Eyedropper => { + let _ = self.input_state.cancel_eyedropper(); + } + ScreenAcquisitionOwner::Ocr => self.input_state.cancel_region_ui_only(), + ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => {} + } + } + + fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner) { + self.cleared_zoom_waiters.push(owner); + } + + fn frozen_generation(&self) -> u64 { + self.frozen_generation + } + + fn frozen_active(&self) -> bool { + self.frozen_active + } + + fn restore_xdg_after_frozen_effect(&mut self) { + self.restore_count += 1; + } + + fn unfreeze_frozen_effect(&mut self) { + self.unfreeze_count += 1; + self.frozen_active = false; + } + + fn abandon_frozen_effect(&mut self) { + self.abandon_count += 1; + self.frozen_active = false; + } + + fn frozen_completion(&self) -> Option { + self.frozen_completion.clone() + } + + fn take_matching_frozen_completion( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + if !self + .frozen_completion + .as_ref() + .is_some_and(|completion| completion.id == id && completion.owner == owner) + { + return None; + } + self.frozen_completion.take() + } + + fn take_frozen_capture_done(&mut self) -> bool { + std::mem::take(&mut self.capture_done) + } + + fn frozen_suppressed(&self) -> bool { + self.frozen_suppressed + } + + fn end_frozen_suppression(&mut self) { + self.frozen_suppressed = false; + self.suppression_end_count += 1; + } +} + +#[test] +fn zoom_owner_outcome_reporting_matrix_is_typed() { + let outcomes = [ + ( + ZoomSourceOutcome::Ready { + installed_generation: 1, + }, + false, + ), + (ZoomSourceOutcome::Cancelled, false), + (ZoomSourceOutcome::StaleLayout, false), + (ZoomSourceOutcome::Aborted, true), + (ZoomSourceOutcome::Deactivated, true), + (ZoomSourceOutcome::Failed("detail".to_string()), true), + ]; + + for owner in [ZoomWaiterOwner::Eyedropper, ZoomWaiterOwner::Ocr] { + for (outcome, reports) in &outcomes { + assert_eq!( + zoom_terminal_report( + Some(owner), + &ZoomSourceTerminal::for_test(outcome.clone(), None), + ) + .is_some(), + *reports, + "owner={owner:?} outcome={outcome:?}" + ); + } + } + for (outcome, _) in &outcomes { + assert_eq!( + zoom_terminal_report( + Some(ZoomWaiterOwner::RegionCapture), + &ZoomSourceTerminal::for_test(outcome.clone(), None), + ), + None + ); + } +} + +#[test] +fn specific_zoom_failure_report_replaces_the_owner_fallback() { + let terminal = ZoomSourceTerminal::for_test( + ZoomSourceOutcome::Failed("specific backend failure".to_string()), + Some(crate::backend::wayland::zoom::ZoomTerminalReport { + source: "zoom", + message: "specific backend failure".to_string(), + }), + ); + + for owner in [Some(ZoomWaiterOwner::Ocr), None] { + let mut input_state = make_test_input_state(); + + report_zoom_terminal_to(&mut input_state, owner, &terminal); + + assert_eq!(input_state.test_toast_count(), 1); + assert_eq!( + input_state.test_active_toast_message(), + Some("specific backend failure") + ); + } +} + +#[test] +fn zoom_correlation_failure_emits_exactly_one_inconsistency_toast() { + let mut input_state = make_test_input_state(); + + report_inconsistent_capture_to(&mut input_state); + + assert_eq!(input_state.test_toast_count(), 1); + assert_eq!( + input_state.test_active_toast_message(), + Some(INCONSISTENT_CAPTURE_MESSAGE) + ); +} + +#[test] +fn screen_terminal_reporting_is_exactly_once_for_the_full_owner_outcome_matrix() { + for owner in [ + ScreenAcquisitionOwner::UserFreeze, + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOwner::RegionCapture, + ] { + let key = match owner { + ScreenAcquisitionOwner::UserFreeze => "freeze", + ScreenAcquisitionOwner::Eyedropper => "eyedropper", + ScreenAcquisitionOwner::Ocr => "ocr", + ScreenAcquisitionOwner::RegionCapture => "capture", + }; + let unavailable = match owner { + ScreenAcquisitionOwner::UserFreeze => Some(( + "capture", + "Freeze is already preparing another overlay operation.", + )), + ScreenAcquisitionOwner::Eyedropper => Some((key, "Screen eyedropper capture failed.")), + ScreenAcquisitionOwner::Ocr => { + Some((key, "Screen capture for text recognition failed.")) + } + ScreenAcquisitionOwner::RegionCapture => None, + }; + let cases = [ + ( + ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + None, + ), + (ScreenAcquisitionOutcome::Cancelled, None), + (ScreenAcquisitionOutcome::Unavailable, unavailable), + ( + ScreenAcquisitionOutcome::StaleLayout, + Some((key, "Freeze failed after the display layout changed")), + ), + ( + ScreenAcquisitionOutcome::Failed("specific backend failure".to_string()), + Some((key, "specific backend failure")), + ), + ]; + + for (outcome, expected) in cases { + let mut input_state = make_test_input_state(); + + report_screen_terminal_to(&mut input_state, owner, &outcome); + + assert_eq!( + input_state.test_toast_count(), + usize::from(expected.is_some()), + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + input_state.test_active_toast_message(), + expected.map(|(_, message)| message), + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + input_state.test_active_toast_key(), + expected.map(|(key, _)| key), + "owner={owner:?} outcome={outcome:?}" + ); + } + } +} + +#[test] +fn matched_ready_completions_run_the_owner_production_transactions() { + for owner in [ + ScreenAcquisitionOwner::UserFreeze, + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ] { + let mut runtime = TransactionRuntime::started(owner); + if owner == ScreenAcquisitionOwner::Ocr { + runtime.frozen_generation = 99; + } + let id = runtime.record().expect("started record").id; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }, + ); + + assert_eq!(runtime.record(), None, "owner={owner:?}"); + assert_eq!( + runtime.finished_eyedropper, + if owner == ScreenAcquisitionOwner::Eyedropper { + vec![7] + } else { + Vec::new() + }, + "owner={owner:?}" + ); + assert_eq!( + runtime.finished_ocr, + if owner == ScreenAcquisitionOwner::Ocr { + vec![7] + } else { + Vec::new() + }, + "owner={owner:?}" + ); + assert_eq!(runtime.input_state.test_toast_count(), 0); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + } +} + +#[test] +fn ready_activation_rejection_releases_the_modal_owned_freeze_once() { + for owner in [ + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ] { + let mut runtime = TransactionRuntime::started(owner); + runtime.ready_activation_succeeds = false; + match owner { + ScreenAcquisitionOwner::Eyedropper => runtime + .input_state + .set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen), + ScreenAcquisitionOwner::Ocr => runtime.input_state.set_region_pending_capture( + crate::input::state::RegionPurposeTag::Ocr, + 1, + ScreenCaptureSource::Frozen, + ), + ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => { + unreachable!() + } + } + assert!(runtime.input_state.screen_modal_is_engaged()); + let id = runtime.record().expect("started record").id; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }, + ); + + assert_eq!(runtime.record(), None, "owner={owner:?}"); + assert!(!runtime.frozen_active, "owner={owner:?}"); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert_eq!(runtime.cancelled_owners, vec![owner], "owner={owner:?}"); + assert!( + !runtime.input_state.screen_modal_is_engaged(), + "owner={owner:?}" + ); + assert_eq!(runtime.input_state.test_toast_count(), 1, "owner={owner:?}"); + assert_eq!( + runtime.input_state.test_active_toast_message(), + Some(SOURCE_ACTIVATION_REJECTED_MESSAGE), + "owner={owner:?}" + ); + } +} + +#[test] +fn matched_nonready_completions_report_then_cancel_the_owner_ui() { + for owner in [ + ScreenAcquisitionOwner::Eyedropper, + ScreenAcquisitionOwner::Ocr, + ] { + for (outcome, expected_toasts) in [ + (ScreenAcquisitionOutcome::Cancelled, 0), + (ScreenAcquisitionOutcome::Unavailable, 1), + (ScreenAcquisitionOutcome::StaleLayout, 1), + ( + ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), + 1, + ), + ] { + let mut runtime = TransactionRuntime::started(owner); + let id = runtime.record().expect("started record").id; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: outcome.clone(), + }, + ); + + assert_eq!( + runtime.record(), + None, + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + runtime.cancelled_owners, + vec![owner], + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!( + runtime.input_state.test_toast_count(), + expected_toasts, + "owner={owner:?} outcome={outcome:?}" + ); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + } + } +} + +#[test] +fn id_owner_waiter_and_empty_slot_mismatches_fail_closed_once() { + let cases = ["id", "owner", "waiter", "empty"]; + for case in cases { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let record = runtime.record().expect("started record"); + let (id, owner) = match case { + "id" => { + let mut other = ScreenAcquisitionRegistry::default(); + other + .request(ScreenAcquisitionOwner::Ocr) + .expect("first other id"); + other.take(); + let different = other + .request(ScreenAcquisitionOwner::Ocr) + .expect("different id"); + (different, ScreenAcquisitionOwner::Ocr) + } + "owner" => (record.id, ScreenAcquisitionOwner::Eyedropper), + "waiter" => { + runtime.waiter_matches = false; + (record.id, record.owner) + } + "empty" => { + runtime.registry.take(); + (record.id, record.owner) + } + _ => unreachable!(), + }; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Failed("failure".to_string()), + }, + ); + + assert_eq!(runtime.record(), None, "case={case}"); + assert_eq!(runtime.input_state.test_toast_count(), 1, "case={case}"); + assert_eq!( + runtime.input_state.test_active_toast_message(), + Some(INCONSISTENT_CAPTURE_MESSAGE), + "case={case}" + ); + assert!(!runtime.frozen_suppressed, "case={case}"); + assert_eq!(runtime.suppression_end_count, 1, "case={case}"); + assert_eq!( + runtime.abandon_count, + usize::from(matches!(case, "id" | "owner")), + "case={case}" + ); + } +} + +#[test] +fn activation_terminal_then_same_batch_cancel_releases_once_and_cleans_up() { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let id = runtime.record().expect("started record").id; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Ocr, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }); + runtime.capture_done = true; + + assert!(cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + + assert_eq!(runtime.record(), None); + assert_eq!(runtime.frozen_completion, None); + assert!(!runtime.capture_done); + assert!(!runtime.frozen_active); + assert_eq!(runtime.restore_count, 1); + assert_eq!(runtime.unfreeze_count, 1); + assert!(!runtime.frozen_suppressed); + assert_eq!(runtime.suppression_end_count, 1); + assert_eq!(runtime.input_state.test_toast_count(), 0); + + assert!(!cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + assert_eq!(runtime.restore_count, 1); + assert_eq!(runtime.unfreeze_count, 1); + assert_eq!(runtime.suppression_end_count, 1); +} + +#[test] +fn started_and_queued_cancellation_have_exact_resource_postconditions() { + let mut started = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); + let started_id = started.record().expect("started record").id; + started.capture_done = true; + + assert!(cancel_acquisition_transaction( + &mut started, + started_id, + ScreenAcquisitionOwner::Eyedropper, + )); + assert_eq!(started.record(), None); + assert_eq!(started.restore_count, 1); + assert_eq!(started.abandon_count, 1); + assert_eq!(started.unfreeze_count, 0); + assert!(!started.capture_done); + assert!(!started.frozen_suppressed); + assert_eq!(started.suppression_end_count, 1); + assert_eq!(started.input_state.test_toast_count(), 0); + + let mut queued = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let queued_record = queued.registry.take().expect("started record"); + let queued_id = queued + .registry + .request(ScreenAcquisitionOwner::Ocr) + .expect("queued replacement"); + assert_ne!(queued_record.id, queued_id); + queued.frozen_suppressed = false; + + assert!(cancel_acquisition_transaction( + &mut queued, + queued_id, + ScreenAcquisitionOwner::Ocr, + )); + assert_eq!(queued.record(), None); + assert_eq!(queued.restore_count, 0); + assert_eq!(queued.abandon_count, 0); + assert_eq!(queued.unfreeze_count, 0); + assert_eq!(queued.suppression_end_count, 0); +} + +#[test] +fn cancellation_reports_matching_nonready_terminals_without_releasing_pixels() { + for outcome in [ + ScreenAcquisitionOutcome::Cancelled, + ScreenAcquisitionOutcome::Unavailable, + ScreenAcquisitionOutcome::StaleLayout, + ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), + ] { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let id = runtime.record().expect("started record").id; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Ocr, + outcome: outcome.clone(), + }); + + assert!(cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + + assert_eq!(runtime.frozen_completion, None, "outcome={outcome:?}"); + assert_eq!(runtime.restore_count, 0, "outcome={outcome:?}"); + assert_eq!(runtime.unfreeze_count, 0, "outcome={outcome:?}"); + assert_eq!( + runtime.input_state.test_toast_count(), + usize::from(!matches!(outcome, ScreenAcquisitionOutcome::Cancelled)), + "outcome={outcome:?}" + ); + assert!(!runtime.frozen_suppressed, "outcome={outcome:?}"); + } +} + +#[test] +fn mismatched_terminal_is_retained_for_fail_closed_routing() { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); + let id = runtime.record().expect("started record").id; + let mut other = ScreenAcquisitionRegistry::default(); + other + .request(ScreenAcquisitionOwner::Ocr) + .expect("other first id"); + other.take(); + let other_id = other + .request(ScreenAcquisitionOwner::Ocr) + .expect("different completion id"); + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id: other_id, + owner: ScreenAcquisitionOwner::Ocr, + outcome: ScreenAcquisitionOutcome::Failed("other".to_string()), + }); + + assert!(!cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Ocr, + )); + + assert!(runtime.record().is_some()); + assert!(runtime.frozen_completion.is_some()); + assert!(runtime.frozen_suppressed); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.abandon_count, 0); + assert_eq!(runtime.input_state.test_toast_count(), 0); +} + +#[test] +fn replacement_generation_survives_ready_cancel_and_stale_release() { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); + let id = runtime.record().expect("started record").id; + runtime.frozen_generation = 8; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::Eyedropper, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }); + + assert!(cancel_acquisition_transaction( + &mut runtime, + id, + ScreenAcquisitionOwner::Eyedropper, + )); + assert!(runtime.frozen_active); + assert_eq!(runtime.frozen_generation, 8); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); + + assert!(!release_owned_generation(&mut runtime, 7)); + assert!(runtime.frozen_active); + assert_eq!(runtime.restore_count, 0); + assert_eq!(runtime.unfreeze_count, 0); +} + +#[test] +fn armed_owner_cleanup_releases_generation_clears_waiter_and_cancels_ui_once() { + for (owner, zoom_owner) in [ + ( + ScreenAcquisitionOwner::Eyedropper, + ZoomWaiterOwner::Eyedropper, + ), + (ScreenAcquisitionOwner::Ocr, ZoomWaiterOwner::Ocr), + ] { + let mut runtime = TransactionRuntime::started(owner); + runtime.registry.take(); + + cancel_modal_owner_resources(&mut runtime, owner, None, Some(7)); + + assert!(!runtime.frozen_active, "owner={owner:?}"); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert_eq!(runtime.cleared_zoom_waiters, vec![zoom_owner]); + assert_eq!(runtime.cancelled_owners, vec![owner]); + + cancel_modal_owner_resources(&mut runtime, owner, None, None); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert_eq!(runtime.cleared_zoom_waiters, vec![zoom_owner, zoom_owner]); + assert_eq!(runtime.cancelled_owners, vec![owner, owner]); + } +} diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 80809760b..3307462ce 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -54,6 +54,7 @@ impl WaylandState { self.exit_overlay_suppression(OverlaySuppression::Zoom); } self.resolve_pending_zoom_terminal(); + self.cancel_screen_modals_if_source_changed(); } /// Restore the overlay after screenshot capture completes. @@ -67,6 +68,12 @@ impl WaylandState { /// Handles capture actions by delegating to the CaptureManager. pub(in crate::backend::wayland) fn handle_capture_action(&mut self, action: Action) { + if self + .input_state + .refuse_region_capture_while_screen_modal_engaged(action) + { + return; + } if !self.config.capture.enabled { log::warn!("Capture action triggered but capture is disabled in config"); return; diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index 64f2749ec..b4274e6ef 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -5,6 +5,7 @@ use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::zoom::ZoomWaiterRegistry; use super::region_capture::ActiveScreenRegion; +use super::screen_image::ScreenSourceToken; use super::capture::OverlayCaptureBarrier; @@ -142,6 +143,7 @@ pub struct StateData { pub(super) pending_freeze_on_start: bool, pub(super) screen_acquisition: ScreenAcquisitionRegistry, pub(super) zoom_waiter: ZoomWaiterRegistry, + pub(super) active_eyedropper_source: Option, pub(super) active_screen_region: Option, pub(super) next_screen_region_generation: u64, pub(super) frozen_enabled: bool, @@ -249,6 +251,7 @@ impl StateData { pending_freeze_on_start: false, screen_acquisition: ScreenAcquisitionRegistry::default(), zoom_waiter: ZoomWaiterRegistry::default(), + active_eyedropper_source: None, active_screen_region: None, next_screen_region_generation: 1, frozen_enabled: false, diff --git a/src/backend/wayland/state/eyedropper.rs b/src/backend/wayland/state/eyedropper.rs index 9a169f9f5..e5bd0c45a 100644 --- a/src/backend/wayland/state/eyedropper.rs +++ b/src/backend/wayland/state/eyedropper.rs @@ -6,9 +6,10 @@ use crate::input::state::EyedropperCaptureSource; use crate::input::state::{Toast, ToastPriority}; use super::WaylandState; +use super::acquisition::report_screen_source_activation_rejected_to; use super::screen_image::{ - DisplayedScreenImage, ScreenSourceEntry, displayed_screen_image, image_point_for_screen_point, - screen_source_entry, screen_source_token, + DisplayedScreenImage, ScreenSourceEntry, current_screen_source_token, displayed_screen_image, + image_point_for_screen_point, screen_source_entry, screen_source_token, }; fn sample_at(image: &FrozenImage, image_x: f64, image_y: f64) -> Option { @@ -75,7 +76,15 @@ impl WaylandState { self.frozen_enabled(), ); match decision { - ScreenSourceEntry::Activate => self.activate_eyedropper_sampler(None), + ScreenSourceEntry::Activate => { + if !self.activate_eyedropper_sampler(None) { + self.cancel_eyedropper_ui_only(); + report_screen_source_activation_rejected_to( + &mut self.input_state, + ScreenAcquisitionOwner::Eyedropper, + ); + } + } ScreenSourceEntry::WaitForZoom => { if self.wait_for_current_zoom_capture(ZoomWaiterOwner::Eyedropper) { self.input_state @@ -146,42 +155,50 @@ impl WaylandState { &mut self, capture_source: EyedropperCaptureSource, owned_frozen_generation: Option, - ) { + ) -> bool { if self.input_state.eyedropper_state().pending_source() != Some(capture_source) { - return; - } - if self.background_image_source().is_some() { - self.activate_eyedropper_sampler(owned_frozen_generation); - } else { - self.cancel_eyedropper_ui_only(); + return false; } + self.activate_eyedropper_sampler(owned_frozen_generation) } /// Arm the screen sampler. Retires an in-flight stylus contact for the same /// reason OCR does: from here the modal swallows the tip-up that would /// otherwise end it. - fn activate_eyedropper_sampler(&mut self, owned_frozen_generation: Option) { + fn activate_eyedropper_sampler(&mut self, owned_frozen_generation: Option) -> bool { + let Some(source) = self.background_image_source() else { + return false; + }; + let Some(token) = current_screen_source_token( + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) else { + return false; + }; self.retire_stylus_contact(); + self.data.active_eyedropper_source = Some(token); self.input_state .activate_eyedropper(owned_frozen_generation); + true } pub(in crate::backend::wayland) fn update_eyedropper_hover(&mut self, x: f64, y: f64) { + self.cancel_screen_modals_if_source_changed(); if self.input_state.eyedropper_is_active() { self.input_state.update_eyedropper_hover((x, y)); } } - pub(in crate::backend::wayland) fn cancel_eyedropper_if_source_missing(&mut self) { - if self.input_state.eyedropper_is_active() && self.background_image_source().is_none() { - self.cancel_eyedropper(); - } - } - pub(in crate::backend::wayland) fn sample_eyedropper(&mut self, x: f64, y: f64) -> bool { if !self.input_state.eyedropper_is_active() { return false; } + self.cancel_screen_modals_if_source_changed(); + if !self.input_state.eyedropper_is_active() { + return true; + } let Some(source) = self.background_image_source() else { self.cancel_eyedropper(); return true; diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 44c2e9586..5616f6513 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -13,6 +13,7 @@ use crate::input::state::{ use crate::ocr::{OcrFailure, OcrLanguages, OcrPoll, OcrRequest, OcrSubmitError, OcrSuccess}; use super::WaylandState; +use super::acquisition::report_screen_source_activation_rejected_to; use super::region_capture::{ ActiveScreenRegion, FreezeOwnership, RegionSelectionFinalize, finalize_region_selection_event, }; @@ -83,7 +84,13 @@ impl WaylandState { self.frozen_enabled(), ) { ScreenSourceEntry::Activate => { - self.activate_ocr_selector(generation, FreezeOwnership::PreExisting); + if !self.activate_ocr_selector(generation, FreezeOwnership::PreExisting) { + self.clear_screen_region_ui_only(); + report_screen_source_activation_rejected_to( + &mut self.input_state, + ScreenAcquisitionOwner::Ocr, + ); + } } ScreenSourceEntry::WaitForZoom => { if self.wait_for_current_zoom_capture(ZoomWaiterOwner::Ocr) { @@ -159,9 +166,9 @@ impl WaylandState { &mut self, capture_source: ScreenCaptureSource, installed_generation: u64, - ) { + ) -> bool { let Some(region) = self.data.active_screen_region else { - return; + return false; }; let generation = region.generation(); let waiting = matches!( @@ -175,20 +182,19 @@ impl WaylandState { ) ); if !waiting { - return; + return false; } - if self.ocr_screen_source().is_some() { - let ownership = if capture_source == ScreenCaptureSource::Frozen { - FreezeOwnership::PickerOwned { - image_generation: installed_generation, - } - } else { - FreezeOwnership::PreExisting - }; - self.activate_ocr_selector(generation, ownership); - } else { - self.cancel_ocr_ui_only(); + if self.ocr_screen_source().is_none() { + return false; } + let ownership = if capture_source == ScreenCaptureSource::Frozen { + FreezeOwnership::PickerOwned { + image_generation: installed_generation, + } + } else { + FreezeOwnership::PreExisting + }; + self.activate_ocr_selector(generation, ownership) } /// Arm the region selector. @@ -199,11 +205,9 @@ impl WaylandState { /// separated: a capture that lands seconds after the request arms the /// selector against a gesture that started during the wait, not the one the /// request itself cancelled. - fn activate_ocr_selector(&mut self, generation: u64, ownership: FreezeOwnership) { + fn activate_ocr_selector(&mut self, generation: u64, ownership: FreezeOwnership) -> bool { self.retire_stylus_contact(); - if !self.activate_screen_region(RegionPurposeTag::Ocr, generation, ownership) { - self.clear_screen_region_ui_only(); - } + self.activate_screen_region(RegionPurposeTag::Ocr, generation, ownership) } /// Leave OCR selection, releasing only a freeze OCR created itself. @@ -237,16 +241,6 @@ impl WaylandState { true } - /// A display or source change invalidates the pixels the user is selecting. - pub(in crate::backend::wayland) fn cancel_ocr_if_source_missing(&mut self) { - if region_source_is_missing( - self.input_state.region_is_active(), - self.ocr_screen_source().is_some(), - ) { - self.cancel_ocr(); - } - } - /// Discard a region because the device dragging it went away — the pen left /// proximity, the touch sequence was cancelled. Devices that are not /// dragging have nothing to withdraw, so this leaves the selector armed for @@ -269,6 +263,11 @@ impl WaylandState { x: f64, y: f64, ) -> bool { + let was_engaged = self.input_state.region_is_engaged(); + self.cancel_screen_modals_if_source_changed(); + if was_engaged && !self.input_state.region_is_engaged() { + return true; + } let rect = match finalize_region_selection_event( &mut self.data.active_screen_region, &mut self.input_state, @@ -454,10 +453,6 @@ fn normalized_rect(start: (f64, f64), end: (f64, f64)) -> (f64, f64, f64, f64) { (x, y, (end.0 - start.0).abs(), (end.1 - start.1).abs()) } -fn region_source_is_missing(region_active: bool, source_present: bool) -> bool { - region_active && !source_present -} - /// Four bracket corners, the visual language a scan frame reads as. The arm /// length shrinks with the region so a small selection is not swallowed by it. fn draw_scan_corners(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64) { @@ -504,11 +499,4 @@ mod tests { assert!(arm >= 4.0); } } - - #[test] - fn active_region_cancels_only_when_its_screen_source_is_missing() { - assert!(region_source_is_missing(true, false)); - assert!(!region_source_is_missing(true, true)); - assert!(!region_source_is_missing(false, false)); - } } diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs index 50c827f78..1a4035d9f 100644 --- a/src/backend/wayland/state/region_capture.rs +++ b/src/backend/wayland/state/region_capture.rs @@ -5,7 +5,9 @@ use crate::input::state::{ use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge, pixel_span}; use super::WaylandState; -use super::screen_image::{ScreenSourceToken, image_point_for_screen_point, screen_source_token}; +use super::screen_image::{ + ScreenSourceToken, current_screen_source_token, image_point_for_screen_point, screen_source_is, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum FreezeOwnership { @@ -288,12 +290,14 @@ impl WaylandState { ) else { return false; }; - let token = screen_source_token( + let Some(token) = current_screen_source_token( &source, &self.zoom, &self.frozen, (self.surface.width(), self.surface.height()), - ); + ) else { + return false; + }; self.data.active_screen_region = Some(ActiveScreenRegion::Ready { purpose, generation, @@ -321,6 +325,7 @@ impl WaylandState { x: f64, y: f64, ) -> bool { + self.cancel_screen_modals_if_source_changed(); begin_region_selection_event( &mut self.data.active_screen_region, &mut self.input_state, @@ -335,6 +340,7 @@ impl WaylandState { x: f64, y: f64, ) { + self.cancel_screen_modals_if_source_changed(); update_region_selection_event( &mut self.data.active_screen_region, &mut self.input_state, @@ -349,6 +355,41 @@ impl WaylandState { self.input_state.region_state(), )); } + + pub(in crate::backend::wayland) fn cancel_screen_modals_if_source_changed(&mut self) { + let (cancel_eyedropper, cancel_ocr) = { + let current_source = super::screen_image::displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ); + let source_matches = |expected| { + current_source.as_ref().is_some_and(|source| { + screen_source_is( + &expected, + source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) + }) + }; + ( + active_eyedropper_source_changed( + self.input_state.eyedropper_is_active(), + self.data.active_eyedropper_source, + &source_matches, + ), + active_ocr_source_changed(self.data.active_screen_region, &source_matches), + ) + }; + if cancel_eyedropper { + self.cancel_eyedropper(); + } + if cancel_ocr { + self.cancel_ocr(); + } + } } fn screen_region_invariant(backend: Option, ui: RegionSelectUiState) -> bool { @@ -361,6 +402,28 @@ fn screen_region_invariant(backend: Option, ui: RegionSelect } } +fn active_ocr_source_changed( + region: Option, + source_matches: &impl Fn(ScreenSourceToken) -> bool, +) -> bool { + matches!( + region, + Some(ActiveScreenRegion::Ready { + purpose: RegionPurposeTag::Ocr, + source, + .. + }) if !source_matches(source) + ) +} + +fn active_eyedropper_source_changed( + active: bool, + expected_source: Option, + source_matches: &impl Fn(ScreenSourceToken) -> bool, +) -> bool { + active && !expected_source.is_some_and(source_matches) +} + pub(super) fn owned_generation_is_current( expected: u64, current: u64, @@ -496,6 +559,62 @@ mod tests { } } + #[test] + fn ready_ocr_detects_replaced_or_missing_source_but_pending_ocr_keeps_waiting() { + let ready = ocr_region(1.0); + let ActiveScreenRegion::Ready { source, .. } = ready else { + unreachable!("OCR fixture is ready") + }; + let mut replacement = source; + replacement.image_generation += 1; + + assert!(!active_ocr_source_changed(Some(ready), &|expected| { + expected == source + })); + assert!(active_ocr_source_changed(Some(ready), &|expected| { + expected == replacement + })); + assert!(active_ocr_source_changed(Some(ready), &|_| false)); + assert!(!active_ocr_source_changed( + Some(ActiveScreenRegion::PendingZoom { + purpose: RegionPurposeTag::Ocr, + generation: 1, + }), + &|_| false, + )); + } + + #[test] + fn active_eyedropper_detects_replaced_missing_or_untracked_source() { + let ActiveScreenRegion::Ready { source, .. } = ocr_region(1.0) else { + unreachable!("OCR fixture is ready") + }; + let mut replacement = source; + replacement.image_generation += 1; + + assert!(!active_eyedropper_source_changed( + true, + Some(source), + &|expected| expected == source, + )); + assert!(active_eyedropper_source_changed( + true, + Some(source), + &|expected| expected == replacement, + )); + assert!(active_eyedropper_source_changed( + true, + Some(source), + &|_| false, + )); + assert!(active_eyedropper_source_changed(true, None, &|_| true)); + assert!(!active_eyedropper_source_changed( + false, + Some(source), + &|_| false, + )); + } + #[test] fn ocr_three_logical_pixels_rearms_but_exactly_four_submits() { let mut region = ocr_region(1.0); diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs index 8999d9efe..ca9bf229b 100644 --- a/src/backend/wayland/state/screen_image.rs +++ b/src/backend/wayland/state/screen_image.rs @@ -168,7 +168,6 @@ pub(super) fn screen_source_token( } } -#[allow(dead_code)] // Phase 0b compares the stored token before routing input. pub(super) fn screen_source_is( expected: &ScreenSourceToken, source: &DisplayedScreenImage<'_>, @@ -176,7 +175,20 @@ pub(super) fn screen_source_is( frozen: &FrozenState, surface: (u32, u32), ) -> bool { - *expected == screen_source_token(source, zoom, frozen, surface) + current_screen_source_token(source, zoom, frozen, surface) == Some(*expected) +} + +pub(super) fn current_screen_source_token( + source: &DisplayedScreenImage<'_>, + zoom: &ZoomState, + frozen: &FrozenState, + surface: (u32, u32), +) -> Option { + let context_matches = match source.kind { + ScreenImageKind::Zoom => zoom.source_context_matches(source.provenance), + ScreenImageKind::Frozen => frozen.source_context_matches(source.provenance), + }; + context_matches.then(|| screen_source_token(source, zoom, frozen, surface)) } pub(super) fn image_point_for_screen_point( @@ -564,7 +576,7 @@ mod tests { } #[test] - fn source_provenance_survives_live_output_changes_and_detects_image_replacement() { + fn source_identity_rejects_live_output_layout_view_and_image_replacement() { use crate::backend::wayland::frozen_geometry::OutputGeometry; use wayland_client::protocol::wl_output; @@ -596,7 +608,13 @@ mod tests { frozen.set_active_output(None, Some(12)); let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); - assert!(screen_source_is(&token, &source, &zoom, &frozen, (100, 50))); + assert!(!screen_source_is( + &token, + &source, + &zoom, + &frozen, + (100, 50) + )); frozen.set_active_output(None, Some(11)); frozen.set_active_geometry(OutputGeometry::update_from( @@ -608,7 +626,13 @@ mod tests { Some((100, 50)), )); let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); - assert!(screen_source_is(&token, &source, &zoom, &frozen, (100, 50))); + assert!(!screen_source_is( + &token, + &source, + &zoom, + &frozen, + (100, 50) + )); zoom.active = true; zoom.set_active_output(None, Some(12)); @@ -639,7 +663,7 @@ mod tests { )); { let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); - assert!(screen_source_is( + assert!(!screen_source_is( &zoom_token, &source, &zoom, diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index 44b597b4f..b2ba8d7fb 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -28,6 +28,7 @@ impl WaylandState { self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; } + self.cancel_screen_modals_if_source_changed(); return; } @@ -39,6 +40,7 @@ impl WaylandState { warn!("Zoom capture failed to start: {}", err); self.zoom.deactivate(&mut self.input_state); self.exit_overlay_suppression(OverlaySuppression::Zoom); + self.cancel_screen_modals_if_source_changed(); } } @@ -118,6 +120,7 @@ impl WaylandState { if self.zoom.is_engaged() { self.zoom.deactivate(&mut self.input_state); self.exit_overlay_suppression(OverlaySuppression::Zoom); + self.cancel_screen_modals_if_source_changed(); } } @@ -143,6 +146,7 @@ impl WaylandState { if cleared { self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; + self.cancel_screen_modals_if_source_changed(); } } @@ -170,6 +174,7 @@ impl WaylandState { .zoom_at_screen_point(factor, screen_x, screen_y, screen_w, screen_h); if self.zoom.active && changed { self.sync_input_zoom_state(); + self.cancel_screen_modals_if_source_changed(); } if self.zoom.is_engaged() @@ -179,6 +184,7 @@ impl WaylandState { warn!("Zoom capture failed to start: {}", err); self.zoom.deactivate(&mut self.input_state); self.exit_overlay_suppression(OverlaySuppression::Zoom); + self.cancel_screen_modals_if_source_changed(); } } diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index c40640c64..9e74d58af 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -200,6 +200,14 @@ impl ZoomState { self.active_geometry = geometry; } + pub(in crate::backend::wayland) fn source_context_matches( + &self, + provenance: ScreenImageProvenance, + ) -> bool { + self.active_output_id == Some(provenance.output_id) + && self.output_layout_generation == provenance.output_layout_generation + } + pub(in crate::backend::wayland) fn image_provenance(&self) -> Option { self.image.as_ref()?; self.image_provenance diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs index 096c3e4dc..43aa88c40 100644 --- a/src/input/state/actions/action_capture_zoom.rs +++ b/src/input/state/actions/action_capture_zoom.rs @@ -1,10 +1,14 @@ use crate::domain::Action; +use crate::input::state::{Toast, ToastPriority}; use crate::input::{OutputFocusAction, ZoomAction}; use super::super::{InputState, PendingBackendAction}; impl InputState { pub(in crate::input::state) fn handle_capture_zoom_action(&mut self, action: Action) -> bool { + if self.refuse_region_capture_while_screen_modal_engaged(action) { + return true; + } match action { Action::CaptureFullScreen | Action::CaptureActiveWindow @@ -101,4 +105,70 @@ impl InputState { _ => false, } } + + pub(crate) fn refuse_region_capture_while_screen_modal_engaged( + &mut self, + action: Action, + ) -> bool { + if !self.screen_modal_is_engaged() + || !matches!( + action, + Action::CaptureSelection + | Action::CaptureClipboardSelection + | Action::CaptureFileSelection + | Action::CaptureClipboardRegion + | Action::CaptureFileRegion + ) + { + return false; + } + self.push_toast( + ToastPriority::Info, + "capture.region.refused", + Toast::info("Finish or cancel the current screen selection first."), + ); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + use crate::input::state::{EyedropperCaptureSource, RegionPurposeTag, ScreenCaptureSource}; + + #[test] + fn region_actions_refuse_without_replacing_an_engaged_screen_modal() { + for action in [ + Action::CaptureSelection, + Action::CaptureClipboardSelection, + Action::CaptureFileSelection, + Action::CaptureClipboardRegion, + Action::CaptureFileRegion, + ] { + for modal in ["ocr", "eyedropper"] { + let mut state = make_test_input_state(); + if modal == "ocr" { + state.set_region_pending_capture( + RegionPurposeTag::Ocr, + 1, + ScreenCaptureSource::Frozen, + ); + } else { + state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); + state.activate_eyedropper(None); + } + + assert!(state.handle_capture_zoom_action(action)); + + assert!(state.take_pending_backend_action().is_none()); + assert!(state.screen_modal_is_engaged()); + assert_eq!(state.test_toast_count(), 1); + assert_eq!( + state.test_active_toast_message(), + Some("Finish or cancel the current screen selection first.") + ); + } + } + } } From 2540524ba172ecc33a4d0635dcd8a2ee1a4c754e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:17:16 +0200 Subject: [PATCH 3/3] fix(capture): start frozen acquisitions in release builds --- src/backend/wayland/backend/event_loop/capture.rs | 2 +- src/backend/wayland/state/acquisition.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 6ef2c4ae2..eb005eeb1 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -274,7 +274,7 @@ fn handle_frozen_toggle(state: &mut WaylandState) { } match state.frozen.start_capture_for(record.id, record.owner) { Ok(()) => { - debug_assert!(state.mark_screen_acquisition_started(record.id, record.owner)); + state.mark_screen_acquisition_started(record.id, record.owner); } Err(err) => { warn!("Frozen capture failed to start: {err}"); diff --git a/src/backend/wayland/state/acquisition.rs b/src/backend/wayland/state/acquisition.rs index d568ac233..f42e175e6 100644 --- a/src/backend/wayland/state/acquisition.rs +++ b/src/backend/wayland/state/acquisition.rs @@ -256,8 +256,12 @@ impl WaylandState { &mut self, id: ScreenAcquisitionId, owner: ScreenAcquisitionOwner, - ) -> bool { - self.data.screen_acquisition.mark_started(id, owner) + ) { + // This transition is a required side effect, not a debug-only check. + // Keeping the mutation outside `debug_assert!` prevents release builds + // from leaving the record queued and starting it again next pass. + let transitioned = self.data.screen_acquisition.mark_started(id, owner); + debug_assert!(transitioned, "the queued acquisition was just started"); } pub(in crate::backend::wayland) fn complete_queued_acquisition(