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 serde::Serialize;
|
||||||
use tauri::Manager;
|
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
|
/// Compositor settle time after hiding the preview overlay before firing
|
||||||
/// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter
|
/// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter
|
||||||
/// Wayland for focus to return to the previously-focused app; shorter
|
/// 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,
|
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)) {
|
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
|
||||||
Ok(()) => outcome.copied = true,
|
Ok(()) => outcome.copied = true,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -77,9 +97,53 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
|
|||||||
Err(err) => outcome.message = Some(err),
|
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)
|
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
|
/// Hide the transcription-preview window if it's currently visible, then
|
||||||
/// sleep a short beat so the compositor can recompute focus. No-ops when
|
/// 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
|
/// 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"))]
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
mod tests {
|
mod tests_linux {
|
||||||
use super::pick_linux_backend_order;
|
use super::pick_linux_backend_order;
|
||||||
|
|
||||||
#[test]
|
#[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