From d31f6787f0801b2d68e5c6320fea810457fa08f4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:20:07 +0200 Subject: [PATCH] refactor(wayland): extract clipboard runtime --- docs/codebase-overview.md | 2 +- src/backend/wayland/state.rs | 28 +- src/backend/wayland/state/AGENTS.md | 2 +- src/backend/wayland/state/clipboard.rs | 51 +- .../wayland/state/clipboard_runtime.rs | 503 ++++++++++++++++++ src/backend/wayland/state/color_picker.rs | 201 +------ src/backend/wayland/state/core/init.rs | 23 +- src/backend/wayland/state/text_clipboard.rs | 475 +---------------- 8 files changed, 566 insertions(+), 719 deletions(-) create mode 100644 src/backend/wayland/state/clipboard_runtime.rs diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 53ede7751..651d15bef 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -77,7 +77,7 @@ Daemon mode therefore provides a persistent background service that reacts to us - Communicate with `capture::CaptureManager` for screenshot actions. - Exit when `InputState.should_exit` is set (Escape, tray close, etc.). -`WaylandState` coordinates the runtime owners handlers need. `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer, cursor, pointer-lock, and single-contact touch protocol lifecycles; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing. The root retains cross-owner input and toolbar routing. +`WaylandState` coordinates the runtime owners handlers need. `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer, cursor, pointer-lock, and single-contact touch protocol lifecycles; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy. The root retains cross-owner input and toolbar routing. Freeze capture waits for the overlay-suppression frame, then selects `wlr-screencopy`, `ext-image-copy-capture`, or the screenshot portal in that order. The two direct protocols capture the active output into shared memory; the portal captures the desktop and the client crops the selected output when needed. Direct capture and portal crop both require compositor-reported output pixels; a missing current mode fails instead of guessing from the overlay buffer. diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index ed3fd5f69..b7d9ce0ad 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -7,10 +7,7 @@ use smithay_client_toolkit::{ globals::ProvidesBoundGlobal, shell::wlr_layer::KeyboardInteractivity, }; -use std::{ - collections::VecDeque, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; use wayland_client::{ Proxy, QueueHandle, protocol::{wl_output, wl_seat}, @@ -39,7 +36,6 @@ use crate::{ }, config::{Action, Config}, desktop_open::DesktopOpenRequest, - input::state::{ClipboardPasteRequest, TextClipboardRequest, TextPasteTarget}, input::{DrawingState, EraserMode, InputState, Tool, ZoomAction}, session::SessionOptions, ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot}, @@ -53,7 +49,6 @@ pub(in crate::backend::wayland) use self::region_capture::WindowSnapDirection; use super::{ RuntimeOperationController, RuntimeOperationIdSource, capture::{CapturePreflightRequest, CaptureState, PendingPdfExport}, - clipboard::{ClipboardPasteCompletion, ClipboardPublishCompletion}, frozen::{ExtImageCopyManagers, FrozenState}, overlay_passthrough::set_surface_clickthrough, session::SessionState, @@ -75,6 +70,10 @@ mod buffer_damage; mod canvas_layer; mod capture; mod clipboard; +mod clipboard_runtime; +pub(in crate::backend::wayland) use clipboard_runtime::{ + HexCopyOutcome, TextCopyOutcome, TextPasteOutcome, +}; mod color_picker; mod core; mod data; @@ -194,21 +193,10 @@ pub(super) struct WaylandState { pub(super) font_catalog_prewarm_started: bool, /// System-reader lifecycle and reconciliation latches for the input HUD. pub(super) input_hud: input_hud::InputHudRuntime, - pub(super) clipboard_publish: RuntimeOperationController, - pub(super) clipboard_paste: - RuntimeOperationController, - pub(super) clipboard_hex_copy: RuntimeOperationController>, + pub(super) clipboard: clipboard_runtime::ClipboardRuntime, /// Desktop-open work completes off-dispatch; successful completion is what /// requests overlay exit, so runtime-owned broker teardown cannot race it. pub(super) desktop_open: RuntimeOperationController>, - pub(super) pending_hex_copy: Option, - /// Async wl-copy pipeline for text-editor selections (Ctrl+C / Ctrl+X). - pub(super) clipboard_text_copy: - RuntimeOperationController>, - pub(super) pending_text_copy: VecDeque, - /// Async wl-paste pipeline for text-editor paste requests (Ctrl+V). - pub(super) clipboard_text_paste: - RuntimeOperationController, String>>, /// Capacity-one compositor window query for the current native region picker. /// Its context owns the picker/source correlation, so stale workers cannot /// mutate a later picker generation. @@ -225,10 +213,6 @@ pub(super) struct WaylandState { region_capture::CutPreviewKey, region_capture::CutPreviewOutcome, >, - /// Text paste requests waiting behind an active read. Repeated requests in - /// the current edit generation remain distinct; a new generation replaces - /// stale queued requests from the old edit session. - pub(super) pending_text_paste: VecDeque, /// Capacity-one screen text recognition. A busy controller reports /// busy rather than queuing a region the user has moved on from. pub(super) ocr: crate::ocr::OcrController, diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index 2e618fc2e..219237bd4 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -6,7 +6,7 @@ ## Architecture - This subtree supports live overlay runtime state: buffers, damage, boards, capture routing, clipboard paste, color picker, onboarding, PDF export, render helpers, toolbar plumbing, zoom, and core accessors. -- Protocol and interaction sub-states extracted from `WaylandState` live beside it: `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer, cursor, pointer-lock, and touch lifecycles), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). +- Protocol and interaction sub-states extracted from `WaylandState` live beside it: `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer, cursor, pointer-lock, and touch lifecycles), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). - `render/` owns overlay render phases; `toolbar/` owns runtime toolbar state helpers; `clipboard/` owns session paste helpers. ## Invariants diff --git a/src/backend/wayland/state/clipboard.rs b/src/backend/wayland/state/clipboard.rs index 57f571cb4..f0ddeb780 100644 --- a/src/backend/wayland/state/clipboard.rs +++ b/src/backend/wayland/state/clipboard.rs @@ -19,7 +19,7 @@ use session_paste::{PastePersistenceDecision, SessionPasteWarning}; impl WaylandState { pub(in crate::backend::wayland) fn drain_clipboard_requests(&mut self) { - if !self.clipboard_publish.is_active() + if !self.clipboard.publish_active() && let Some(request) = self.input_state.take_pending_selection_clipboard_publish() { self.start_selection_clipboard_publish(request.generation, request.payload_json); @@ -27,14 +27,14 @@ impl WaylandState { if let Some(request) = take_pending_clipboard_paste_if_idle( &mut self.input_state, - self.clipboard_paste.is_active(), + self.clipboard.paste_active(), ) { self.start_clipboard_paste(request); } } pub(in crate::backend::wayland) fn poll_clipboard_publish_completion(&mut self) { - match self.clipboard_publish.poll() { + match self.clipboard.poll_publish() { RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} RuntimeOperationPoll::Ready { id, @@ -76,7 +76,7 @@ impl WaylandState { } pub(in crate::backend::wayland) fn poll_clipboard_paste_completion(&mut self) { - match self.clipboard_paste.poll() { + match self.clipboard.poll_paste() { RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} RuntimeOperationPoll::Ready { id, @@ -125,12 +125,7 @@ impl WaylandState { fn start_selection_clipboard_publish(&mut self, generation: u64, payload_json: String) { self.suppress_focus_exit_for(Duration::from_millis(1500)); - if let Err(failure) = - self.clipboard_publish - .try_submit(generation, "clipboard-publish", move || { - transfer::resolve_selection_clipboard_publish(generation, payload_json) - }) - { + if let Err(failure) = self.clipboard.submit_publish(generation, payload_json) { let (error, generation) = failure.into_parts(); log::warn!("Could not submit clipboard publish operation: {error}"); self.apply_selection_clipboard_publish_completion(failed_clipboard_publish_completion( @@ -374,8 +369,8 @@ impl WaylandState { ); let context = request.clone(); if let Err(failure) = - self.clipboard_paste - .try_submit(context, "clipboard-fingerprint-probe", move || { + self.clipboard + .submit_paste(context, "clipboard-fingerprint-probe", move || { let started = Instant::now(); let current = clipboard::clipboard_fingerprint(); log::info!( @@ -400,8 +395,7 @@ impl WaylandState { request.id ); self.apply_clipboard_paste_completion(failed_clipboard_paste_completion( - request, - &error.to_string(), + request, &error, )); } } @@ -409,19 +403,19 @@ impl WaylandState { fn start_system_clipboard_read(&mut self, request: ClipboardPasteRequest) { log::info!("Reading system clipboard for paste request {}", request.id); let context = request.clone(); - if let Err(failure) = - self.clipboard_paste - .try_submit(context, "clipboard-paste", move || { - let started = Instant::now(); - let result = transfer::resolve_system_clipboard(); - log::info!( - "System clipboard read for paste request {} completed in {:?}: {}", - request.id, - started.elapsed(), - result.summary() - ); - ClipboardPasteCompletion { request, result } - }) + if let Err(failure) = self + .clipboard + .submit_paste(context, "clipboard-paste", move || { + let started = Instant::now(); + let result = transfer::resolve_system_clipboard(); + log::info!( + "System clipboard read for paste request {} completed in {:?}: {}", + request.id, + started.elapsed(), + result.summary() + ); + ClipboardPasteCompletion { request, result } + }) { let (error, request) = failure.into_parts(); log::warn!( @@ -429,8 +423,7 @@ impl WaylandState { request.id ); self.apply_clipboard_paste_completion(failed_clipboard_paste_completion( - request, - &error.to_string(), + request, &error, )); } } diff --git a/src/backend/wayland/state/clipboard_runtime.rs b/src/backend/wayland/state/clipboard_runtime.rs new file mode 100644 index 000000000..adf3966a8 --- /dev/null +++ b/src/backend/wayland/state/clipboard_runtime.rs @@ -0,0 +1,503 @@ +use std::collections::VecDeque; + +use crate::backend::wayland::{ + RuntimeOperationController, RuntimeOperationIdSource, RuntimeOperationPoll, + RuntimeOperationSubmitFailure, RuntimeWakeHandle, + clipboard::{ClipboardPasteCompletion, ClipboardPublishCompletion, transfer}, +}; +use crate::clipboard_text::{ + ClipboardTextError, copy_text_via_command, read_clipboard_text_via_command, +}; +use crate::input::state::{ + ClipboardPasteRequest, TextClipboardRequest, TextPasteEdit, TextPasteTarget, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum HexCopyOutcome { + Copied, + Failed, +} + +impl HexCopyOutcome { + pub(in crate::backend::wayland) const fn message(&self) -> &'static str { + match self { + Self::Copied => "Copied to clipboard", + Self::Failed => "Failed to copy to clipboard", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum TextCopyOutcome { + Copied, + Failed, +} + +impl TextCopyOutcome { + pub(in crate::backend::wayland) const fn message(&self) -> &'static str { + match self { + Self::Copied => "Copied to clipboard", + Self::Failed => "Failed to copy to clipboard", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum TextPasteOutcome { + Text(String), + Empty, + Failed, +} + +pub(in crate::backend::wayland) struct ClipboardPasteSubmitFailure { + error: String, + request: Box, +} + +impl ClipboardPasteSubmitFailure { + pub(in crate::backend::wayland) fn into_parts(self) -> (String, ClipboardPasteRequest) { + (self.error, *self.request) + } +} + +impl TextPasteOutcome { + pub(in crate::backend::wayland) const fn message(&self) -> &'static str { + match self { + Self::Text(_) => "Pasted from clipboard", + Self::Empty => "Clipboard empty", + Self::Failed => "Failed to paste from clipboard", + } + } +} + +/// Single-flight clipboard workers and their request-coalescing queues. +pub(in crate::backend::wayland) struct ClipboardRuntime { + publish: RuntimeOperationController, + paste: RuntimeOperationController, + hex_copy: RuntimeOperationController, + pending_hex_copy: Option, + text_copy: RuntimeOperationController, + pending_text_copy: VecDeque, + text_paste: RuntimeOperationController, + pending_text_paste: VecDeque, +} + +impl ClipboardRuntime { + pub(super) fn new(ids: RuntimeOperationIdSource, wake: RuntimeWakeHandle) -> Self { + Self { + publish: RuntimeOperationController::new(ids.clone(), wake.clone()), + paste: RuntimeOperationController::new(ids.clone(), wake.clone()), + hex_copy: RuntimeOperationController::new(ids.clone(), wake.clone()), + pending_hex_copy: None, + text_copy: RuntimeOperationController::new(ids.clone(), wake.clone()), + pending_text_copy: VecDeque::new(), + text_paste: RuntimeOperationController::new(ids, wake), + pending_text_paste: VecDeque::new(), + } + } + + pub(in crate::backend::wayland) fn publish_active(&self) -> bool { + self.publish.is_active() + } + + pub(in crate::backend::wayland) fn submit_publish( + &mut self, + generation: u64, + payload_json: String, + ) -> Result<(), RuntimeOperationSubmitFailure> { + self.publish + .try_submit(generation, "clipboard-publish", move || { + transfer::resolve_selection_clipboard_publish(generation, payload_json) + }) + .map(drop) + } + + pub(in crate::backend::wayland) fn poll_publish( + &mut self, + ) -> RuntimeOperationPoll { + self.publish.poll() + } + + pub(in crate::backend::wayland) fn paste_active(&self) -> bool { + self.paste.is_active() + } + + pub(in crate::backend::wayland) fn submit_paste( + &mut self, + request: ClipboardPasteRequest, + thread_name: &'static str, + operation: impl FnOnce() -> ClipboardPasteCompletion + Send + 'static, + ) -> Result<(), ClipboardPasteSubmitFailure> { + self.paste + .try_submit(request, thread_name, operation) + .map(drop) + .map_err(|failure| { + let (error, request) = failure.into_parts(); + ClipboardPasteSubmitFailure { + error: error.to_string(), + request: Box::new(request), + } + }) + } + + pub(in crate::backend::wayland) fn poll_paste( + &mut self, + ) -> RuntimeOperationPoll { + self.paste.poll() + } + + pub(in crate::backend::wayland) fn queue_hex_copy( + &mut self, + hex: String, + ) -> Result<(), String> { + self.enqueue_hex_copy(hex); + self.submit_pending_hex_copy_if_idle() + } + + fn enqueue_hex_copy(&mut self, hex: String) { + self.pending_hex_copy = Some(hex); + } + + pub(in crate::backend::wayland) fn submit_pending_hex_copy_if_idle( + &mut self, + ) -> Result<(), String> { + if self.hex_copy.is_active() { + return Ok(()); + } + let Some(hex) = self.pending_hex_copy.take() else { + return Ok(()); + }; + let worker_hex = hex.clone(); + self.hex_copy + .try_submit( + hex, + "wayscriber-hex-copy", + move || match copy_text_via_command(&worker_hex) { + Ok(()) => HexCopyOutcome::Copied, + Err(error) => { + log::warn!("wl-copy failed for hex copy: {error}"); + HexCopyOutcome::Failed + } + }, + ) + .map(drop) + .map_err(|failure| failure.into_parts().0.to_string()) + } + + pub(in crate::backend::wayland) fn poll_hex_copy( + &mut self, + ) -> RuntimeOperationPoll { + self.hex_copy.poll() + } + + pub(in crate::backend::wayland) fn queue_text_copy( + &mut self, + request: TextClipboardRequest, + ) -> Result<(), String> { + self.enqueue_text_copy(request); + self.submit_pending_text_copy_if_idle() + } + + fn enqueue_text_copy(&mut self, request: TextClipboardRequest) { + if request.cut.is_none() + && let Some(previous) = self.pending_text_copy.back_mut() + && previous.cut.is_none() + { + *previous = request; + } else { + self.pending_text_copy.push_back(request); + } + } + + pub(in crate::backend::wayland) fn submit_pending_text_copy_if_idle( + &mut self, + ) -> Result<(), String> { + if self.text_copy.is_active() { + return Ok(()); + } + let Some(request) = self.pending_text_copy.pop_front() else { + return Ok(()); + }; + let worker_text = request.text.clone(); + self.text_copy + .try_submit( + request, + "wayscriber-text-copy", + move || match copy_text_via_command(&worker_text) { + Ok(()) => TextCopyOutcome::Copied, + Err(error) => { + log::warn!("wl-copy failed for text copy: {error}"); + TextCopyOutcome::Failed + } + }, + ) + .map(drop) + .map_err(|failure| failure.into_parts().0.to_string()) + } + + pub(in crate::backend::wayland) fn poll_text_copy( + &mut self, + ) -> RuntimeOperationPoll { + self.text_copy.poll() + } + + pub(in crate::backend::wayland) fn queue_text_paste( + &mut self, + target: TextPasteTarget, + ) -> Result<(), String> { + self.enqueue_text_paste(target); + self.submit_pending_text_paste_if_idle() + } + + fn enqueue_text_paste(&mut self, target: TextPasteTarget) { + if self + .pending_text_paste + .back() + .is_some_and(|pending| pending.generation != target.generation) + { + self.pending_text_paste.clear(); + } + self.pending_text_paste.push_back(target); + } + + pub(in crate::backend::wayland) fn take_pending_text_paste_if_idle( + &mut self, + ) -> Option { + (!self.text_paste.is_active()) + .then(|| self.pending_text_paste.pop_front()) + .flatten() + } + + pub(in crate::backend::wayland) fn submit_text_paste( + &mut self, + target: TextPasteTarget, + ) -> Result<(), String> { + self.text_paste + .try_submit(target, "wayscriber-text-paste", read_text_paste) + .map(drop) + .map_err(|failure| failure.into_parts().0.to_string()) + } + + pub(in crate::backend::wayland) fn submit_pending_text_paste_if_idle( + &mut self, + ) -> Result<(), String> { + let Some(target) = self.take_pending_text_paste_if_idle() else { + return Ok(()); + }; + self.submit_text_paste(target) + } + + pub(in crate::backend::wayland) fn poll_text_paste( + &mut self, + ) -> RuntimeOperationPoll { + self.text_paste.poll() + } + + pub(in crate::backend::wayland) fn rebase_pending_text_pastes(&mut self, edit: &TextPasteEdit) { + for target in &mut self.pending_text_paste { + rebase_text_paste_target(target, edit); + } + } +} + +fn read_text_paste() -> TextPasteOutcome { + match read_clipboard_text_via_command() { + Ok(text) => TextPasteOutcome::Text(text), + Err(ClipboardTextError::Empty) => TextPasteOutcome::Empty, + Err(ClipboardTextError::Other(error)) => { + log::warn!("wl-paste failed for text paste: {error}"); + TextPasteOutcome::Failed + } + } +} + +fn rebase_text_paste_target(target: &mut TextPasteTarget, edit: &TextPasteEdit) { + if target.generation != edit.generation || target.revision != edit.previous_revision { + return; + } + + target.caret = rebase_text_paste_offset(target.caret, edit); + target.selection_anchor = target + .selection_anchor + .map(|anchor| rebase_text_paste_offset(anchor, edit)); + if target.selection_anchor == Some(target.caret) { + target.selection_anchor = None; + } + target.revision = edit.revision; +} + +fn rebase_text_paste_offset(offset: usize, edit: &TextPasteEdit) -> usize { + if offset < edit.replaced.start { + offset + } else if offset > edit.replaced.end { + edit.replaced.start + edit.inserted_len + (offset - edit.replaced.end) + } else { + edit.replaced.start + edit.inserted_len + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::{RuntimeOperationIdSource, RuntimeWakeSource}; + + fn runtime() -> ClipboardRuntime { + let wake = RuntimeWakeSource::new().expect("runtime wake source"); + ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()) + } + + fn copy_request(text: &str) -> TextClipboardRequest { + TextClipboardRequest { + text: text.to_string(), + cut: None, + } + } + + fn cut_request(text: &str, start: usize) -> TextClipboardRequest { + TextClipboardRequest { + text: text.to_string(), + cut: Some(crate::input::state::TextCutTarget { + generation: 7, + revision: 3, + range: start..start + text.len(), + }), + } + } + + fn paste_target(generation: u64) -> TextPasteTarget { + TextPasteTarget { + generation, + revision: 3, + caret: 5, + selection_anchor: None, + } + } + + #[test] + fn active_hex_copy_retains_only_the_newest_request() { + let mut runtime = runtime(); + runtime + .hex_copy + .try_submit_with_spawner_for_test( + "#000000".to_string(), + || HexCopyOutcome::Copied, + |_job| Ok(()), + ) + .expect("test transport starts"); + + runtime.queue_hex_copy("#111111".to_string()).unwrap(); + runtime.queue_hex_copy("#222222".to_string()).unwrap(); + + assert_eq!(runtime.pending_hex_copy.as_deref(), Some("#222222")); + } + + #[test] + fn active_text_copy_replaces_pending_copies_but_preserves_cuts() { + let mut runtime = runtime(); + runtime + .text_copy + .try_submit_with_spawner_for_test( + copy_request("active"), + || TextCopyOutcome::Copied, + |_job| Ok(()), + ) + .expect("test transport starts"); + + runtime.queue_text_copy(copy_request("older")).unwrap(); + runtime.queue_text_copy(cut_request("cut", 0)).unwrap(); + runtime.queue_text_copy(copy_request("replace me")).unwrap(); + runtime.queue_text_copy(copy_request("newest")).unwrap(); + + assert_eq!( + runtime + .pending_text_copy + .iter() + .map(|request| (request.text.as_str(), request.cut.is_some())) + .collect::>(), + [("older", false), ("cut", true), ("newest", false)] + ); + } + + #[test] + fn newer_text_session_supersedes_older_pending_pastes_without_coalescing_its_own() { + let mut runtime = runtime(); + runtime + .text_paste + .try_submit_with_spawner_for_test( + paste_target(1), + || TextPasteOutcome::Empty, + |_job| Ok(()), + ) + .expect("test transport starts"); + + runtime.queue_text_paste(paste_target(1)).unwrap(); + runtime.queue_text_paste(paste_target(1)).unwrap(); + runtime.queue_text_paste(paste_target(2)).unwrap(); + runtime.queue_text_paste(paste_target(2)).unwrap(); + + assert_eq!( + runtime + .pending_text_paste + .iter() + .map(|target| target.generation) + .collect::>(), + [2, 2] + ); + } + + #[test] + fn active_text_paste_defers_pending_requests() { + let mut runtime = runtime(); + runtime + .text_paste + .try_submit_with_spawner_for_test( + paste_target(1), + || TextPasteOutcome::Empty, + |_job| Ok(()), + ) + .expect("test transport starts"); + runtime.enqueue_text_paste(paste_target(1)); + + assert_eq!(runtime.take_pending_text_paste_if_idle(), None); + assert_eq!(runtime.pending_text_paste.len(), 1); + } + + #[test] + fn queued_paste_target_rebases_after_an_earlier_paste() { + let mut runtime = runtime(); + runtime.pending_text_paste.push_back(TextPasteTarget { + generation: 7, + revision: 3, + caret: 5, + selection_anchor: Some(2), + }); + + runtime.rebase_pending_text_pastes(&TextPasteEdit { + generation: 7, + previous_revision: 3, + revision: 4, + replaced: 2..5, + inserted_len: 4, + }); + + assert_eq!( + runtime.pending_text_paste.front(), + Some(&TextPasteTarget { + generation: 7, + revision: 4, + caret: 6, + selection_anchor: None, + }) + ); + } + + #[test] + fn typed_outcomes_expose_stable_user_messages() { + assert_eq!( + HexCopyOutcome::Failed.message(), + "Failed to copy to clipboard" + ); + assert_eq!(TextCopyOutcome::Copied.message(), "Copied to clipboard"); + assert_eq!(TextPasteOutcome::Empty.message(), "Clipboard empty"); + } +} diff --git a/src/backend/wayland/state/color_picker.rs b/src/backend/wayland/state/color_picker.rs index 8771e38d1..1faf321c8 100644 --- a/src/backend/wayland/state/color_picker.rs +++ b/src/backend/wayland/state/color_picker.rs @@ -1,10 +1,8 @@ //! Clipboard helpers for color hex values. -use super::{RuntimeOperationController, WaylandState}; +use super::{HexCopyOutcome, WaylandState}; use crate::backend::wayland::RuntimeOperationPoll; -use crate::clipboard_text::{ - ClipboardTextError, copy_text_via_command, read_clipboard_text_via_command, -}; +use crate::clipboard_text::{ClipboardTextError, read_clipboard_text_via_command}; use crate::draw::Color; use crate::input::state::{HexPasteTarget, Toast, ToastPriority}; use crate::input::state::{color_to_hex, parse_hex_color}; @@ -17,12 +15,7 @@ impl WaylandState { log::info!("Hex copy requested: {}", hex); self.suppress_focus_exit_for(Duration::from_millis(1500)); - if let Err(err) = queue_latest_clipboard_copy( - &mut self.clipboard_hex_copy, - &mut self.pending_hex_copy, - hex, - copy_text_via_command, - ) { + if let Err(err) = self.clipboard.queue_hex_copy(hex) { log::warn!("Failed to start hex clipboard copy: {err}"); self.input_state.push_toast( ToastPriority::Info, @@ -33,11 +26,11 @@ impl WaylandState { } pub(in crate::backend::wayland) fn poll_hex_copy_completion(&mut self) { - match self.clipboard_hex_copy.poll() { + match self.clipboard.poll_hex_copy() { RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} RuntimeOperationPoll::Ready { context: hex, - outcome: Ok(()), + outcome: HexCopyOutcome::Copied, .. } => { self.input_state.push_toast( @@ -48,14 +41,14 @@ impl WaylandState { } RuntimeOperationPoll::Ready { context: hex, - outcome: Err(err), + outcome: HexCopyOutcome::Failed, .. } => { - log::warn!("wl-copy failed for hex copy {hex}: {err}"); + log::warn!("wl-copy failed for hex copy {hex}"); self.input_state.push_toast( ToastPriority::Info, "color_picker", - Toast::warning("Failed to copy to clipboard"), + Toast::warning(HexCopyOutcome::Failed.message()), ); } RuntimeOperationPoll::ProducerFailed { @@ -83,11 +76,7 @@ impl WaylandState { } fn start_pending_hex_copy_if_idle(&mut self) { - if let Err(err) = submit_pending_clipboard_copy_if_idle( - &mut self.clipboard_hex_copy, - &mut self.pending_hex_copy, - copy_text_via_command, - ) { + if let Err(err) = self.clipboard.submit_pending_hex_copy_if_idle() { log::warn!("Failed to start pending hex clipboard copy: {err}"); self.input_state.push_toast( ToastPriority::Info, @@ -169,175 +158,3 @@ impl WaylandState { } } } - -fn start_clipboard_copy( - controller: &mut RuntimeOperationController>, - hex: String, - operation: impl FnOnce(&str) -> Result<(), String> + Send + 'static, -) -> Result<(), String> { - let worker_hex = hex.clone(); - controller - .try_submit(hex, "wayscriber-hex-copy", move || operation(&worker_hex)) - .map(drop) - .map_err(|failure| failure.into_parts().0.to_string()) -} - -pub(super) fn queue_latest_clipboard_copy( - controller: &mut RuntimeOperationController>, - pending: &mut Option, - hex: String, - operation: impl FnOnce(&str) -> Result<(), String> + Send + 'static, -) -> Result<(), String> { - *pending = Some(hex); - submit_pending_clipboard_copy_if_idle(controller, pending, operation) -} - -pub(super) fn submit_pending_clipboard_copy_if_idle( - controller: &mut RuntimeOperationController>, - pending: &mut Option, - operation: impl FnOnce(&str) -> Result<(), String> + Send + 'static, -) -> Result<(), String> { - if controller.is_active() { - return Ok(()); - } - let Some(hex) = pending.take() else { - return Ok(()); - }; - start_clipboard_copy(controller, hex, operation) -} - -#[cfg(test)] -mod tests { - use std::sync::mpsc; - use std::time::Duration; - - use super::*; - use crate::backend::wayland::RuntimeOperationIdSource; - use crate::backend::wayland::RuntimeWakeSource; - - #[test] - fn hex_copy_submission_stays_off_the_event_thread_until_completion() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - - start_clipboard_copy(&mut controller, "#123456".to_string(), move |hex| { - assert_eq!(hex, "#123456"); - started_tx.send(()).unwrap(); - release_rx - .recv_timeout(Duration::from_secs(1)) - .map_err(|error| error.to_string())?; - Ok(()) - }) - .unwrap(); - - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Pending { .. } - )); - release_tx.send(()).unwrap(); - assert!( - wake.wait_readable(Some(Duration::from_secs(1))).unwrap(), - "hex copy completion did not wake the event loop" - ); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Ready { - context, - outcome: Ok(()), - .. - } if context == "#123456" - )); - } - - #[test] - fn active_hex_copy_retains_only_the_newest_pending_request() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let mut pending = None; - let (first_started_tx, first_started_rx) = mpsc::channel(); - let (first_release_tx, first_release_rx) = mpsc::channel(); - - queue_latest_clipboard_copy( - &mut controller, - &mut pending, - "#111111".to_string(), - move |hex| { - assert_eq!(hex, "#111111"); - first_started_tx.send(()).unwrap(); - first_release_rx - .recv_timeout(Duration::from_secs(1)) - .map_err(|error| error.to_string())?; - Ok(()) - }, - ) - .unwrap(); - first_started_rx - .recv_timeout(Duration::from_secs(1)) - .unwrap(); - - queue_latest_clipboard_copy( - &mut controller, - &mut pending, - "#222222".to_string(), - |_| -> Result<(), String> { panic!("busy submission must not run") }, - ) - .unwrap(); - queue_latest_clipboard_copy( - &mut controller, - &mut pending, - "#333333".to_string(), - |_| -> Result<(), String> { panic!("busy submission must not run") }, - ) - .unwrap(); - assert_eq!(pending.as_deref(), Some("#333333")); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Pending { .. } - )); - - first_release_tx.send(()).unwrap(); - assert!(wake.wait_readable(Some(Duration::from_secs(1))).unwrap()); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Ready { - context, - outcome: Ok(()), - .. - } if context == "#111111" - )); - - let (newest_started_tx, newest_started_rx) = mpsc::channel(); - let (newest_release_tx, newest_release_rx) = mpsc::channel(); - submit_pending_clipboard_copy_if_idle(&mut controller, &mut pending, move |hex| { - newest_started_tx.send(hex.to_string()).unwrap(); - newest_release_rx - .recv_timeout(Duration::from_secs(1)) - .map_err(|error| error.to_string())?; - Ok(()) - }) - .unwrap(); - assert_eq!(pending, None); - assert_eq!( - newest_started_rx - .recv_timeout(Duration::from_secs(1)) - .unwrap(), - "#333333" - ); - - newest_release_tx.send(()).unwrap(); - assert!(wake.wait_readable(Some(Duration::from_secs(1))).unwrap()); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Ready { - context, - outcome: Ok(()), - .. - } if context == "#333333" - )); - } -} diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index 459beffba..50a39df55 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -92,18 +92,12 @@ impl WaylandState { let runtime_operation_ids = RuntimeOperationIdSource::new(); let font_catalog_prewarm = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let clipboard_publish = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let clipboard_paste = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let clipboard_hex_copy = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); + let clipboard = super::super::clipboard_runtime::ClipboardRuntime::new( + runtime_operation_ids.clone(), + runtime_wake.clone(), + ); let desktop_open = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let clipboard_text_copy = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let clipboard_text_paste = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); let window_query = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); let region_cut_preview = @@ -126,15 +120,8 @@ impl WaylandState { font_catalog_prewarm, font_catalog_prewarm_started: false, palette_recents, - clipboard_publish, - clipboard_paste, - clipboard_hex_copy, + clipboard, desktop_open, - pending_hex_copy: None, - clipboard_text_copy, - pending_text_copy: Default::default(), - clipboard_text_paste, - pending_text_paste: Default::default(), window_query, region_cut_preview, ocr, diff --git a/src/backend/wayland/state/text_clipboard.rs b/src/backend/wayland/state/text_clipboard.rs index 7c969cdfd..160dd4398 100644 --- a/src/backend/wayland/state/text_clipboard.rs +++ b/src/backend/wayland/state/text_clipboard.rs @@ -8,15 +8,10 @@ //! handlers, drained each input cycle, fulfill it against the compositor //! clipboard (reusing the generic clipboard worker pipeline). -use super::{RuntimeOperationController, WaylandState}; +use super::{TextCopyOutcome, TextPasteOutcome, WaylandState}; use crate::backend::wayland::RuntimeOperationPoll; -use crate::clipboard_text::{ - ClipboardTextError, copy_text_via_command, read_clipboard_text_via_command, -}; -use crate::input::state::{ - TextClipboardRequest, TextPasteEdit, TextPasteTarget, Toast, ToastPriority, -}; -use std::{collections::VecDeque, time::Duration}; +use crate::input::state::{TextClipboardRequest, TextPasteTarget, Toast, ToastPriority}; +use std::time::Duration; impl WaylandState { /// Publish captured text-selection to the system clipboard (Ctrl+C / X). @@ -25,12 +20,7 @@ impl WaylandState { return; } self.suppress_focus_exit_for(Duration::from_millis(1500)); - if let Err(err) = queue_text_copy( - &mut self.clipboard_text_copy, - &mut self.pending_text_copy, - request, - copy_text_via_command, - ) { + if let Err(err) = self.clipboard.queue_text_copy(request) { log::warn!("Failed to start text clipboard copy: {err}"); self.input_state.push_toast( ToastPriority::Info, @@ -43,21 +33,21 @@ impl WaylandState { /// Poll the async text-copy pipeline, surface failures, and restart a /// queued copy once the controller goes idle. pub(in crate::backend::wayland) fn poll_text_copy_completion(&mut self) { - match self.clipboard_text_copy.poll() { + match self.clipboard.poll_text_copy() { RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} RuntimeOperationPoll::Ready { context: request, - outcome: Ok(()), + outcome: TextCopyOutcome::Copied, .. } => self.input_state.complete_text_copy(request), RuntimeOperationPoll::Ready { - outcome: Err(err), .. + outcome: TextCopyOutcome::Failed, + .. } => { - log::warn!("wl-copy failed for text copy: {err}"); self.input_state.push_toast( ToastPriority::Info, "text_clipboard", - Toast::warning("Failed to copy to clipboard"), + Toast::warning(TextCopyOutcome::Failed.message()), ); } RuntimeOperationPoll::ProducerFailed { reason, .. } => { @@ -72,11 +62,7 @@ impl WaylandState { log::error!("Text copy producer disconnected"); } } - if let Err(err) = submit_pending_text_copy_if_idle( - &mut self.clipboard_text_copy, - &mut self.pending_text_copy, - copy_text_via_command, - ) { + if let Err(err) = self.clipboard.submit_pending_text_copy_if_idle() { log::warn!("Failed to start pending text clipboard copy: {err}"); } } @@ -87,12 +73,7 @@ impl WaylandState { return; } self.suppress_focus_exit_for(Duration::from_millis(1500)); - if let Err(err) = queue_text_paste( - &mut self.clipboard_text_paste, - &mut self.pending_text_paste, - target, - read_text_paste, - ) { + if let Err(err) = self.clipboard.queue_text_paste(target) { log::warn!("Failed to start text clipboard paste: {err}"); self.push_text_paste_failure(); } @@ -101,7 +82,7 @@ impl WaylandState { /// Poll the async text-paste read and apply it only to the originating /// text-edit session. A later edit must never receive a stale completion. pub(in crate::backend::wayland) fn poll_text_paste_completion(&mut self) { - match self.clipboard_text_paste.poll() { + match self.clipboard.poll_text_paste() { RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} RuntimeOperationPoll::Ready { context: target, @@ -110,18 +91,13 @@ impl WaylandState { } => { if self.input_state.text_paste_target_is_current(target) { match outcome { - Ok(Some(text)) => { + TextPasteOutcome::Text(text) => { if let Some(edit) = self.input_state.apply_text_paste(target, &text) { - for pending in &mut self.pending_text_paste { - rebase_text_paste_target(pending, &edit); - } + self.clipboard.rebase_pending_text_pastes(&edit); } } - Ok(None) => {} - Err(err) => { - log::warn!("wl-paste failed for text paste: {err}"); - self.push_text_paste_failure(); - } + TextPasteOutcome::Empty => {} + TextPasteOutcome::Failed => self.push_text_paste_failure(), } } else { log::debug!("Discarding stale text clipboard paste completion"); @@ -150,17 +126,12 @@ impl WaylandState { } fn start_pending_text_paste_if_idle(&mut self) { - if self.clipboard_text_paste.is_active() { - return; - } - while let Some(target) = self.pending_text_paste.pop_front() { + while let Some(target) = self.clipboard.take_pending_text_paste_if_idle() { if !self.input_state.text_paste_target_is_current(target) { log::debug!("Discarding stale queued text clipboard paste request"); continue; } - if let Err(err) = - start_text_paste(&mut self.clipboard_text_paste, target, read_text_paste) - { + if let Err(err) = self.clipboard.submit_text_paste(target) { log::warn!("Failed to start pending text clipboard paste: {err}"); self.push_text_paste_failure(); } @@ -172,415 +143,7 @@ impl WaylandState { self.input_state.push_toast( ToastPriority::Info, "text_clipboard", - Toast::warning("Failed to paste from clipboard"), - ); - } -} - -fn start_text_copy( - controller: &mut RuntimeOperationController>, - request: TextClipboardRequest, - operation: impl FnOnce(&str) -> Result<(), String> + Send + 'static, -) -> Result<(), String> { - let worker_text = request.text.clone(); - controller - .try_submit(request, "wayscriber-text-copy", move || { - operation(&worker_text) - }) - .map(drop) - .map_err(|failure| failure.into_parts().0.to_string()) -} - -fn queue_text_copy( - controller: &mut RuntimeOperationController>, - pending: &mut VecDeque, - request: TextClipboardRequest, - operation: impl FnOnce(&str) -> Result<(), String> + Send + 'static, -) -> Result<(), String> { - if request.cut.is_none() - && let Some(previous) = pending.back_mut() - && previous.cut.is_none() - { - *previous = request; - } else { - pending.push_back(request); - } - submit_pending_text_copy_if_idle(controller, pending, operation) -} - -fn submit_pending_text_copy_if_idle( - controller: &mut RuntimeOperationController>, - pending: &mut VecDeque, - operation: impl FnOnce(&str) -> Result<(), String> + Send + 'static, -) -> Result<(), String> { - if controller.is_active() { - return Ok(()); - } - let Some(request) = pending.pop_front() else { - return Ok(()); - }; - start_text_copy(controller, request, operation) -} - -type TextPasteOutcome = Result, String>; - -fn read_text_paste() -> TextPasteOutcome { - match read_clipboard_text_via_command() { - Ok(text) => Ok(Some(text)), - Err(ClipboardTextError::Empty) => Ok(None), - Err(ClipboardTextError::Other(err)) => Err(err), - } -} - -fn start_text_paste( - controller: &mut RuntimeOperationController, - target: TextPasteTarget, - operation: impl FnOnce() -> TextPasteOutcome + Send + 'static, -) -> Result<(), String> { - controller - .try_submit(target, "wayscriber-text-paste", operation) - .map(drop) - .map_err(|failure| failure.into_parts().0.to_string()) -} - -fn queue_text_paste( - controller: &mut RuntimeOperationController, - pending: &mut VecDeque, - target: TextPasteTarget, - operation: impl FnOnce() -> TextPasteOutcome + Send + 'static, -) -> Result<(), String> { - if pending - .back() - .is_some_and(|pending_target| pending_target.generation != target.generation) - { - pending.clear(); - } - pending.push_back(target); - if controller.is_active() { - return Ok(()); - } - // Submit from the front so queued requests keep their arrival order; the - // target just pushed is the only entry whenever the controller is idle. - let Some(target) = pending.pop_front() else { - return Ok(()); - }; - start_text_paste(controller, target, operation) -} - -fn rebase_text_paste_target(target: &mut TextPasteTarget, edit: &TextPasteEdit) { - if target.generation != edit.generation || target.revision != edit.previous_revision { - return; - } - - target.caret = rebase_text_paste_offset(target.caret, edit); - target.selection_anchor = target - .selection_anchor - .map(|anchor| rebase_text_paste_offset(anchor, edit)); - if target.selection_anchor == Some(target.caret) { - target.selection_anchor = None; - } - target.revision = edit.revision; -} - -fn rebase_text_paste_offset(offset: usize, edit: &TextPasteEdit) -> usize { - if offset < edit.replaced.start { - offset - } else if offset > edit.replaced.end { - edit.replaced.start + edit.inserted_len + (offset - edit.replaced.end) - } else { - edit.replaced.start + edit.inserted_len - } -} - -#[cfg(test)] -mod tests { - use std::collections::VecDeque; - use std::sync::mpsc; - use std::time::Duration; - - use super::*; - use crate::backend::wayland::RuntimeOperationIdSource; - use crate::backend::wayland::RuntimeWakeSource; - - fn copy_request(text: &str) -> TextClipboardRequest { - TextClipboardRequest { - text: text.to_string(), - cut: None, - } - } - - fn cut_request(text: &str, start: usize) -> TextClipboardRequest { - TextClipboardRequest { - text: text.to_string(), - cut: Some(crate::input::state::TextCutTarget { - generation: 7, - revision: 3, - range: start..start + text.len(), - }), - } - } - - fn paste_target(generation: u64) -> TextPasteTarget { - TextPasteTarget { - generation, - revision: 3, - caret: 5, - selection_anchor: None, - } - } - - #[test] - fn text_copy_keeps_its_request_context_until_worker_completion() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - - start_text_copy(&mut controller, copy_request("selected"), |text| { - assert_eq!(text, "selected"); - Ok(()) - }) - .unwrap(); - - assert!( - wake.wait_readable(Some(Duration::from_secs(1))).unwrap(), - "text copy completion did not wake the event loop" - ); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Ready { - context: TextClipboardRequest { text, .. }, - outcome: Ok(()), - .. - } if text == "selected" - )); - } - - #[test] - fn active_text_copy_retains_only_the_newest_request() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let mut pending = VecDeque::new(); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - - queue_text_copy( - &mut controller, - &mut pending, - copy_request("first"), - move |_| { - started_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - Ok(()) - }, - ) - .unwrap(); - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - queue_text_copy( - &mut controller, - &mut pending, - copy_request("second"), - |_| panic!("busy submission must not run"), - ) - .unwrap(); - queue_text_copy( - &mut controller, - &mut pending, - copy_request("newest"), - |_| panic!("busy submission must not run"), - ) - .unwrap(); - - assert_eq!( - pending.front().map(|request| request.text.as_str()), - Some("newest") - ); - release_tx.send(()).unwrap(); - } - - #[test] - fn active_text_copy_preserves_every_pending_cut_request() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let mut pending = VecDeque::new(); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - - queue_text_copy( - &mut controller, - &mut pending, - copy_request("first"), - move |_| { - started_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - Ok(()) - }, - ) - .unwrap(); - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - queue_text_copy( - &mut controller, - &mut pending, - cut_request("second", 0), - |_| panic!("busy submission must not run"), - ) - .unwrap(); - queue_text_copy( - &mut controller, - &mut pending, - cut_request("third", 6), - |_| panic!("busy submission must not run"), - ) - .unwrap(); - - assert_eq!( - pending - .iter() - .map(|request| request.text.as_str()) - .collect::>(), - ["second", "third"] - ); - release_tx.send(()).unwrap(); - } - - #[test] - fn text_paste_read_stays_off_the_event_thread_until_completion() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - - start_text_paste(&mut controller, paste_target(7), move || { - started_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - Ok(Some("clipboard text".to_string())) - }) - .unwrap(); - - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Pending { .. } - )); - release_tx.send(()).unwrap(); - assert!( - wake.wait_readable(Some(Duration::from_secs(1))).unwrap(), - "text paste completion did not wake the event loop" - ); - assert!(matches!( - controller.poll(), - RuntimeOperationPoll::Ready { - context: TextPasteTarget { generation: 7, .. }, - outcome: Ok(Some(text)), - .. - } if text == "clipboard text" - )); - } - - #[test] - fn active_text_paste_preserves_every_request_from_the_same_session() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let mut pending = VecDeque::new(); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - - queue_text_paste(&mut controller, &mut pending, paste_target(7), move || { - started_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - Ok(Some("first".to_string())) - }) - .unwrap(); - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - queue_text_paste(&mut controller, &mut pending, paste_target(7), || { - panic!("busy submission must not run") - }) - .unwrap(); - queue_text_paste(&mut controller, &mut pending, paste_target(7), || { - panic!("busy submission must not run") - }) - .unwrap(); - - assert_eq!( - pending - .iter() - .map(|target| target.generation) - .collect::>(), - [7, 7] - ); - release_tx.send(()).unwrap(); - } - - #[test] - fn newer_text_session_supersedes_older_pending_pastes_without_coalescing_its_own() { - let wake = RuntimeWakeSource::new().unwrap(); - let mut controller = - RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); - let mut pending = VecDeque::new(); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - - queue_text_paste(&mut controller, &mut pending, paste_target(1), move || { - started_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - Ok(Some("stale".to_string())) - }) - .unwrap(); - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - for generation in [1, 1, 2, 2] { - queue_text_paste( - &mut controller, - &mut pending, - paste_target(generation), - || panic!("busy submission must not run"), - ) - .unwrap(); - } - - assert_eq!( - pending - .iter() - .map(|target| target.generation) - .collect::>(), - [2, 2] - ); - release_tx.send(()).unwrap(); - } - - #[test] - fn queued_paste_target_rebases_after_an_earlier_paste() { - let mut target = TextPasteTarget { - generation: 7, - revision: 3, - caret: 5, - selection_anchor: Some(2), - }; - rebase_text_paste_target( - &mut target, - &TextPasteEdit { - generation: 7, - previous_revision: 3, - revision: 4, - replaced: 2..5, - inserted_len: 4, - }, - ); - - assert_eq!( - target, - TextPasteTarget { - generation: 7, - revision: 4, - caret: 6, - selection_anchor: None, - } + Toast::warning(TextPasteOutcome::Failed.message()), ); } }