feat(B.1 #3): snapshot prior clipboard and restore 300 ms after paste
Extends commands/paste.rs so that when auto-paste fires we:
1. capture the user's existing clipboard text BEFORE overwriting
it with the transcript (via arboard::Clipboard::get_text — a
non-text clipboard returns None and the restore step is
skipped, which keeps images / files safe),
2. after the paste keystroke lands, sleep 300 ms,
3. restore the snapshot only if the clipboard still holds the
transcript we wrote — respects any Cmd+C the user did in the
300 ms window.
Moves the decision to a pure should_restore(current, transcript)
helper with four unit tests. The existing paste-matrix regression
tests are unchanged.
Matches Handy #921 (workstream-B brief item #3). Coexists with the
Wayland preview-hide dance already in this file, and doesn't run
when the paste keystroke fails (transcript stays on the clipboard
for the user's own paste).
Co-authored-by: jars <jakejars@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,13 @@ use arboard::Clipboard;
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
|
||||
/// Window after the paste keystroke at which we restore the user's
|
||||
/// prior clipboard content. 300 ms is enough for even a slow Wayland
|
||||
/// compositor to have fully delivered the synthesised Ctrl+V to the
|
||||
/// focused app; longer risks the user manually pasting again and
|
||||
/// stomping themselves. See Handy #921.
|
||||
const CLIPBOARD_RESTORE_MS: u64 = 300;
|
||||
|
||||
/// Compositor settle time after hiding the preview overlay before firing
|
||||
/// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter
|
||||
/// Wayland for focus to return to the previously-focused app; shorter
|
||||
@@ -59,6 +66,19 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
|
||||
message: None,
|
||||
};
|
||||
|
||||
// Snapshot the user's existing clipboard text BEFORE we stomp it
|
||||
// with the transcript, so a background task can restore it after
|
||||
// the paste keystroke has landed. `arboard::Clipboard::get_text`
|
||||
// returns Err when the clipboard holds non-text content (images,
|
||||
// files, empty) — in those cases we skip the restore step
|
||||
// entirely rather than trying to coerce. This is the "never
|
||||
// silently clobber the user's clipboard" contract from Handy
|
||||
// #921 (Workstream B brief item #3).
|
||||
let prior_clipboard: Option<String> = match Clipboard::new() {
|
||||
Ok(mut cb) => cb.get_text().ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
|
||||
Ok(()) => outcome.copied = true,
|
||||
Err(err) => {
|
||||
@@ -77,9 +97,53 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
|
||||
Err(err) => outcome.message = Some(err),
|
||||
}
|
||||
|
||||
// Fire-and-forget: restore the prior clipboard in the background
|
||||
// after CLIPBOARD_RESTORE_MS, regardless of whether the paste
|
||||
// succeeded. If paste failed, the transcript is still on the
|
||||
// clipboard and Kon's own "Copy" button remains the user's
|
||||
// recovery path; overwriting it with the restore would undo that.
|
||||
// So only restore when the keystroke actually fired.
|
||||
if outcome.pasted {
|
||||
if let Some(prior) = prior_clipboard.clone() {
|
||||
let transcript_clone = text.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await;
|
||||
restore_prior_clipboard(&prior, &transcript_clone);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Write `prior` back to the clipboard only if the current clipboard
|
||||
/// content is still the transcript we just set — i.e. the user hasn't
|
||||
/// already copied something new themselves in the last 300 ms. This
|
||||
/// avoids stomping a deliberate Cmd+C that happened right after the
|
||||
/// paste.
|
||||
fn restore_prior_clipboard(prior: &str, transcript: &str) {
|
||||
let mut cb = match Clipboard::new() {
|
||||
Ok(cb) => cb,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if should_restore(cb.get_text().ok().as_deref(), transcript) {
|
||||
let _ = cb.set_text(prior.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure decision function: should we restore the prior clipboard?
|
||||
///
|
||||
/// - Some(current) == transcript → yes, the clipboard still holds
|
||||
/// what we wrote, so restore is safe.
|
||||
/// - Some(current) != transcript → no, user has already copied
|
||||
/// something else in the 300 ms window; respect it.
|
||||
/// - None → couldn't read the clipboard (image, no access, etc.);
|
||||
/// conservative no-op.
|
||||
fn should_restore(current: Option<&str>, transcript: &str) -> bool {
|
||||
matches!(current, Some(text) if text == transcript)
|
||||
}
|
||||
|
||||
/// Hide the transcription-preview window if it's currently visible, then
|
||||
/// sleep a short beat so the compositor can recompute focus. No-ops when
|
||||
/// the window isn't registered yet (user never enabled the overlay) or
|
||||
@@ -262,7 +326,7 @@ fn windows_paste() -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
mod tests_linux {
|
||||
use super::pick_linux_backend_order;
|
||||
|
||||
#[test]
|
||||
@@ -297,3 +361,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard_restore {
|
||||
use super::should_restore;
|
||||
|
||||
#[test]
|
||||
fn restores_when_clipboard_still_holds_transcript() {
|
||||
assert!(should_restore(Some("dictated text"), "dictated text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_restore_when_user_copied_something_else() {
|
||||
assert!(!should_restore(
|
||||
Some("user copied this just now"),
|
||||
"dictated text"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_restore_when_clipboard_unreadable() {
|
||||
assert!(!should_restore(None, "dictated text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_restore_on_empty_mismatch() {
|
||||
assert!(!should_restore(Some(""), "dictated text"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user