Skip to main content

hyprcorrect_platform/linux/
emit.rs

1//! Linux synthetic text input.
2//!
3//! Corrections are applied by shelling out to `wtype`, which drives the
4//! Wayland `virtual-keyboard-v1` protocol. (A native, dependency-free
5//! implementation of that protocol is a later refinement — see
6//! `DESIGN.md`.)
7
8use std::io::ErrorKind;
9use std::process::Command;
10use std::time::Duration;
11
12use super::capture;
13
14/// How long we'll wait for the user to release the chord
15/// (Ctrl/Shift/Alt/Super) before giving up and emitting anyway.
16/// Tuned to feel instant when the user taps-and-releases, but
17/// generous enough to cover a slow release.
18const MODS_CLEAR_TIMEOUT_MS: u64 = 250;
19
20/// An error applying a text replacement.
21#[derive(Debug, thiserror::Error)]
22pub enum EmitError {
23    /// `wtype` is not installed.
24    #[error(
25        "`wtype` is not installed — install it (e.g. `sudo pacman -S wtype`) so hyprcorrect can type corrections"
26    )]
27    WtypeMissing,
28    /// `wtype` ran but exited with a failure.
29    #[error("`wtype` failed to apply the correction")]
30    WtypeFailed,
31}
32
33/// Per-key delay used inside each `wtype` burst. Was 2 ms originally
34/// — large enough to give wtype's protocol a flush point per event,
35/// small enough to feel instant. But terminals (Ghostty, foot, …)
36/// drop the occasional BackSpace under that pressure when the burst
37/// is 5+ keys: the result is leftover characters that escape the
38/// deletion (e.g., `mothr → motherr` instead of `mother`). 8 ms
39/// per key is still imperceptible for normal-length words and is
40/// reliably swallowed by every terminal we've tested.
41const WTYPE_INTER_KEY_DELAY_MS: u32 = 8;
42
43/// Apply an edit at the caret: press Backspace `backspaces` times, then
44/// type `text`. Uses the default per-backspace pause.
45///
46/// # Errors
47///
48/// Returns [`EmitError`] if `wtype` is missing or exits non-zero.
49pub fn replace(backspaces: usize, text: &str) -> Result<(), EmitError> {
50    replace_with_delay(backspaces, text, 8, 8)
51}
52
53/// Like [`replace`], but lets the caller set the pause-per-backspace
54/// in milliseconds. The pause is applied as a single sleep between
55/// the backspace burst and the replacement-text burst, scaled by the
56/// number of backspaces so longer edits wait proportionally longer.
57///
58/// Wayland delivers wtype's events reliably; what this pause covers
59/// is the time the focused app needs to *apply* the backspaces
60/// through its own event loop before our next `wtype` (the typing
61/// burst) starts queueing text events behind the still-processing
62/// deletes.
63///
64/// Backspaces and text are emitted as *two separate* `wtype`
65/// invocations so the focused app has a clean event boundary
66/// between them.
67///
68/// # Errors
69///
70/// Returns [`EmitError`] if `wtype` is missing or exits non-zero.
71pub fn replace_with_delay(
72    backspaces: usize,
73    text: &str,
74    pause_per_backspace_ms: u32,
75    pause_per_char_ms: u32,
76) -> Result<(), EmitError> {
77    replace_around_caret_with_delay(
78        backspaces,
79        0,
80        text,
81        pause_per_backspace_ms,
82        pause_per_char_ms,
83    )
84}
85
86/// Like [`replace_with_delay`] but also emits Delete keys (right of
87/// the caret) before typing the replacement. Used by fix-word /
88/// fix-sentence when the caret is INSIDE a word or sentence: we
89/// can't backspace away text on the right side of the caret, so we
90/// hand the focused app `BackSpace × N` then `Delete × M` then the
91/// new text.
92///
93/// `pause_per_backspace_ms` scales the drain pause by the total
94/// number of editing keystrokes (backspaces + deletes), since both
95/// kinds of edits queue in the focused app's event loop the same
96/// way.
97///
98/// # Errors
99///
100/// Returns [`EmitError`] if `wtype` is missing or exits non-zero.
101pub fn replace_around_caret_with_delay(
102    backspaces: usize,
103    deletes: usize,
104    text: &str,
105    pause_per_backspace_ms: u32,
106    pause_per_char_ms: u32,
107) -> Result<(), EmitError> {
108    // Wait for the user to release the trigger chord before we
109    // type anything. Many Wayland compositors deliver wtype's
110    // synthetic keys ORed with the user's physical modifier
111    // state, so a `BackSpace` while Ctrl is still held arrives at
112    // the focused window as Ctrl+BackSpace (delete-word, in most
113    // terminals). On timeout we fall through and emit anyway —
114    // the user may be holding an unrelated modifier on purpose.
115    let _ = capture::wait_mods_clear(Duration::from_millis(MODS_CLEAR_TIMEOUT_MS));
116
117    // Implementation strategy: "delete N chars to the right of the
118    // caret" is rewritten as "move caret right N, then backspace N
119    // more." Every deletion ends up going through `BackSpace`,
120    // which TUIs and editors handle uniformly. Sending Delete keys
121    // directly worked unreliably — under fast bursts terminals'
122    // input parsers were dropping the trailing keystrokes, leaving
123    // chars on screen.
124    //
125    // Three phases, each its own wtype call with a drain pause:
126    // 1. Right arrow × `deletes` — moves caret to the right edge of
127    //    the region we want gone.
128    // 2. BackSpace × (`backspaces` + `deletes`) — drains the whole
129    //    region left of the now-rightmost caret position.
130    // 3. Type the replacement text.
131    if deletes > 0 {
132        let mut cmd = Command::new("wtype");
133        cmd.args(["-d", &WTYPE_INTER_KEY_DELAY_MS.to_string()]);
134        for _ in 0..deletes {
135            cmd.args(["-P", "Right", "-p", "Right"]);
136        }
137        run(cmd)?;
138        sleep_ms(pause_per_backspace_ms, deletes);
139    }
140    let total_backspaces = backspaces + deletes;
141    if total_backspaces > 0 {
142        let mut cmd = Command::new("wtype");
143        cmd.args(["-d", &WTYPE_INTER_KEY_DELAY_MS.to_string()]);
144        for _ in 0..total_backspaces {
145            cmd.args(["-P", "BackSpace", "-p", "BackSpace"]);
146        }
147        run(cmd)?;
148        sleep_ms(pause_per_backspace_ms, total_backspaces);
149    }
150    type_text(text, pause_per_char_ms)?;
151    Ok(())
152}
153
154/// Replace a word at a *known position relative to end-of-line*.
155/// `chars_from_end` is the number of Left arrows needed to walk
156/// from end-of-line back to the END of the word to replace;
157/// `word_chars` is the BackSpace count to remove the word once
158/// the cursor is on it.
159///
160/// Anchored at `End` (rather than relative to the user's current
161/// caret) so held-arrow undercount / mouse clicks / any other
162/// way the buffer's caret can drift from the visible cursor
163/// don't cause the emit to land at the wrong spot. The buffer's
164/// *text* tracks what's actually on screen reliably — only the
165/// caret offset is fragile — so counting chars back from
166/// end-of-line is rock solid as long as the focused app's `End`
167/// goes to end-of-line (shells, single-line text inputs, most
168/// terminals do; multi-line editors may not).
169///
170/// Same mod-clear gate runs first.
171///
172/// # Errors
173///
174/// Returns [`EmitError`] if `wtype` is missing or exits non-zero.
175pub fn anchored_replace_with_delay(
176    chars_from_end: usize,
177    word_chars: usize,
178    insert: &str,
179    pause_per_backspace_ms: u32,
180    pause_per_char_ms: u32,
181) -> Result<(), EmitError> {
182    let _ = capture::wait_mods_clear(Duration::from_millis(MODS_CLEAR_TIMEOUT_MS));
183
184    // Anchor: jump the cursor to end-of-line.
185    {
186        let mut cmd = Command::new("wtype");
187        cmd.args(["-d", &WTYPE_INTER_KEY_DELAY_MS.to_string()]);
188        cmd.args(["-P", "End", "-p", "End"]);
189        run(cmd)?;
190        sleep_ms(pause_per_backspace_ms, 1);
191    }
192    if chars_from_end > 0 {
193        let mut cmd = Command::new("wtype");
194        cmd.args(["-d", &WTYPE_INTER_KEY_DELAY_MS.to_string()]);
195        for _ in 0..chars_from_end {
196            cmd.args(["-P", "Left", "-p", "Left"]);
197        }
198        run(cmd)?;
199        sleep_ms(pause_per_backspace_ms, chars_from_end);
200    }
201    if word_chars > 0 {
202        let mut cmd = Command::new("wtype");
203        cmd.args(["-d", &WTYPE_INTER_KEY_DELAY_MS.to_string()]);
204        for _ in 0..word_chars {
205            cmd.args(["-P", "BackSpace", "-p", "BackSpace"]);
206        }
207        run(cmd)?;
208        sleep_ms(pause_per_backspace_ms, word_chars);
209    }
210    type_text(insert, pause_per_char_ms)?;
211    Ok(())
212}
213
214/// Type `text` as a `wtype` burst, emitting embedded newlines as
215/// Shift+Enter rather than a bare Return. A plain Return submits
216/// chat-style inputs (the Claude Code prompt, Slack, Discord, …);
217/// Shift+Enter inserts a line break instead, so applying a multi-line
218/// correction never sends the message. Each line is its own text burst
219/// with a Shift+Enter key event between them; a string with no newline
220/// is a single burst, identical to the old behavior. Empty input is a
221/// no-op.
222///
223/// `pause_per_char_ms` (the "Pause per character" knob) sets wtype's
224/// inter-key delay for the typing burst directly — the user tunes it to
225/// their own apps/terminals. Note this is independent of the
226/// Backspace/arrow bursts, which keep the fixed
227/// [`WTYPE_INTER_KEY_DELAY_MS`] (a *deletion* reliability concern, where
228/// terminals drop keys under fast bursts — see that const's doc); the
229/// typing burst has no such requirement, so it follows the knob with no
230/// floor.
231fn type_text(text: &str, pause_per_char_ms: u32) -> Result<(), EmitError> {
232    let mut first = true;
233    for line in text.split('\n') {
234        if !first {
235            let mut cmd = Command::new("wtype");
236            cmd.args(["-M", "shift", "-k", "Return", "-m", "shift"]);
237            run(cmd)?;
238        }
239        first = false;
240        if !line.is_empty() {
241            let mut cmd = Command::new("wtype");
242            cmd.args(["-d", &pause_per_char_ms.to_string()]);
243            cmd.arg("--").arg(line);
244            run(cmd)?;
245        }
246    }
247    Ok(())
248}
249
250fn sleep_ms(pause_per_backspace_ms: u32, count: usize) {
251    let total = u64::from(pause_per_backspace_ms).saturating_mul(count as u64);
252    if total > 0 {
253        std::thread::sleep(std::time::Duration::from_millis(total));
254    }
255}
256
257fn run(mut cmd: Command) -> Result<(), EmitError> {
258    let status = cmd.status().map_err(|e| match e.kind() {
259        ErrorKind::NotFound => EmitError::WtypeMissing,
260        _ => EmitError::WtypeFailed,
261    })?;
262    if status.success() {
263        Ok(())
264    } else {
265        Err(EmitError::WtypeFailed)
266    }
267}