hyprcorrect_platform/linux/clipboard.rs
1//! Clipboard / selection fallback (M5).
2//!
3//! When the focused window's keystroke buffer is empty — typically
4//! because focus moved and the user wants to fix something they
5//! *didn't just type* — we simulate Ctrl+Shift+Left to select the
6//! previous word, Ctrl+C to copy it, read the clipboard via
7//! `wl-paste`, run the offline corrector over it, and then type
8//! the correction. The selection is still active when we type, so
9//! the replacement overwrites it in place.
10//!
11//! Best-effort: doesn't work in terminals (no select-previous-word
12//! shortcut) or in apps that interpret Ctrl+Shift+Left differently.
13//! Per `DESIGN.md`'s secondary-mode notes.
14
15use std::process::Command;
16use std::thread::sleep;
17use std::time::Duration;
18
19/// Errors from a clipboard-fallback round trip.
20#[derive(Debug, thiserror::Error)]
21pub enum ClipboardError {
22 /// `wtype` or `wl-paste` could not be spawned (typically not
23 /// installed). The daemon falls back silently — no fix happens,
24 /// but the user can install `wl-clipboard` to enable it.
25 #[error("could not run {0}: {1}")]
26 Spawn(String, String),
27 /// The helper exited non-zero — usually means the protocol or
28 /// the compositor refused the request.
29 #[error("{0} exited non-zero: {1}")]
30 Exit(String, String),
31 /// `wl-paste` returned empty — the select-previous-word
32 /// keystroke didn't land (terminal, no-text-field, …).
33 #[error("clipboard was empty after the copy step — selection likely failed")]
34 Empty,
35 /// The clipboard contained non-UTF-8 bytes — we don't try to
36 /// correct image / PDF / etc. payloads.
37 #[error("clipboard contents were not valid UTF-8")]
38 NotUtf8,
39}
40
41/// Select the previous word, copy it, and return the contents.
42/// Leaves the selection active so a subsequent
43/// [`type_replacement`] call overwrites it.
44pub fn copy_previous_word() -> Result<String, ClipboardError> {
45 // Select previous word: Ctrl+Shift+Left (press, then release in
46 // the reverse order). wtype's `-M` is modifier-press, `-k` is
47 // keysym press+release, `-m` is modifier-release.
48 wtype(&[
49 "-M", "ctrl", "-M", "shift", "-k", "Left", "-m", "shift", "-m", "ctrl",
50 ])?;
51 sleep(Duration::from_millis(30));
52
53 // Copy: Ctrl+C.
54 wtype(&["-M", "ctrl", "-k", "c", "-m", "ctrl"])?;
55 sleep(Duration::from_millis(80)); // compositor + clipboard manager
56
57 let output = Command::new("wl-paste")
58 .arg("-n") // strip trailing newline
59 .output()
60 .map_err(|e| ClipboardError::Spawn("wl-paste".into(), e.to_string()))?;
61 if !output.status.success() {
62 return Err(ClipboardError::Exit(
63 "wl-paste".into(),
64 String::from_utf8_lossy(&output.stderr).into_owned(),
65 ));
66 }
67 let text = std::str::from_utf8(&output.stdout)
68 .map_err(|_| ClipboardError::NotUtf8)?
69 .to_string();
70 if text.is_empty() {
71 return Err(ClipboardError::Empty);
72 }
73 Ok(text)
74}
75
76/// Type the replacement text. With a selection still active (from
77/// [`copy_previous_word`]), this overwrites the selection in
78/// place. Safe to call standalone too.
79pub fn type_replacement(text: &str) -> Result<(), ClipboardError> {
80 // `--` ends wtype's option parsing so a replacement that starts
81 // with `-` won't be parsed as a flag.
82 let output = Command::new("wtype")
83 .arg("--")
84 .arg(text)
85 .output()
86 .map_err(|e| ClipboardError::Spawn("wtype".into(), e.to_string()))?;
87 if !output.status.success() {
88 return Err(ClipboardError::Exit(
89 "wtype".into(),
90 String::from_utf8_lossy(&output.stderr).into_owned(),
91 ));
92 }
93 Ok(())
94}
95
96fn wtype(args: &[&str]) -> Result<(), ClipboardError> {
97 let output = Command::new("wtype")
98 .args(args)
99 .output()
100 .map_err(|e| ClipboardError::Spawn("wtype".into(), e.to_string()))?;
101 if !output.status.success() {
102 return Err(ClipboardError::Exit(
103 "wtype".into(),
104 String::from_utf8_lossy(&output.stderr).into_owned(),
105 ));
106 }
107 Ok(())
108}