Skip to main content

hyprcorrect_core/
replace.rs

1//! Replacement planning: turning a chosen correction into a concrete
2//! edit — a backspace count, a delete count, and the text to type —
3//! over the buffered text.
4//!
5//! See the "Replacement mechanics" section of `DESIGN.md`.
6
7use crate::buffer::WordAtCaret;
8
9/// A concrete edit for the emulation layer to apply to the focused
10/// application: press Backspace `backspaces` times, press Delete
11/// `deletes` times, then type `insert`. Splitting the deletion into
12/// a left half (Backspace) and a right half (Delete) lets us
13/// rewrite a word the caret sits inside without first having to
14/// move the caret to the end of it.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Edit {
17    /// Number of Backspace presses to send (chars before the caret).
18    pub backspaces: usize,
19    /// Number of Delete presses to send (chars after the caret).
20    pub deletes: usize,
21    /// Text to type after the deletions.
22    pub insert: String,
23}
24
25/// Plan the edit that replaces the word at the caret with `correction`,
26/// preserving the whitespace the user typed after it.
27///
28/// Returns `None` when `correction` already equals the word: there is
29/// nothing to do, and sending a no-op edit would only risk disturbing
30/// the caret.
31pub fn plan_word_replacement(at: &WordAtCaret, correction: &str) -> Option<Edit> {
32    if correction == at.word {
33        return None;
34    }
35    let trailing_chars = at.trailing.chars().count();
36    Some(Edit {
37        // Left of caret: the word's left half + any whitespace
38        // between the word's right edge and the caret.
39        backspaces: at.chars_before_caret + trailing_chars,
40        // Right of caret: the word's right half (zero when caret
41        // was at the word's end or in trailing whitespace).
42        deletes: at.chars_after_caret,
43        // Retype the correction then put the trailing whitespace
44        // back so the caret lands where the user expects.
45        insert: format!("{correction}{}", at.trailing),
46    })
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    fn word_at_end(word: &str, trailing: &str) -> WordAtCaret {
54        WordAtCaret {
55            word: word.to_string(),
56            trailing: trailing.to_string(),
57            chars_before_caret: word.chars().count(),
58            chars_after_caret: 0,
59        }
60    }
61
62    #[test]
63    fn replaces_word_and_keeps_the_trailing_space() {
64        let edit = plan_word_replacement(&word_at_end("vernuer", " "), "veneer").unwrap();
65        assert_eq!(
66            edit,
67            Edit {
68                backspaces: 8,
69                deletes: 0,
70                insert: "veneer ".to_string(),
71            }
72        );
73    }
74
75    #[test]
76    fn replaces_word_with_no_trailing_whitespace() {
77        let edit = plan_word_replacement(&word_at_end("vernuer", ""), "veneer").unwrap();
78        assert_eq!(edit.backspaces, 7);
79        assert_eq!(edit.deletes, 0);
80        assert_eq!(edit.insert, "veneer");
81    }
82
83    #[test]
84    fn no_edit_when_the_word_is_already_correct() {
85        assert_eq!(
86            plan_word_replacement(&word_at_end("veneer", " "), "veneer"),
87            None
88        );
89    }
90
91    #[test]
92    fn backspace_count_covers_all_trailing_whitespace() {
93        let edit = plan_word_replacement(&word_at_end("x", "   "), "y").unwrap();
94        assert_eq!(edit.backspaces, 4);
95        assert_eq!(edit.deletes, 0);
96        assert_eq!(edit.insert, "y   ");
97    }
98
99    #[test]
100    fn caret_inside_word_splits_into_backspaces_plus_deletes() {
101        // Caret sits between "ver" and "nuer" — 3 chars left, 4 right.
102        let at = WordAtCaret {
103            word: "vernuer".to_string(),
104            trailing: String::new(),
105            chars_before_caret: 3,
106            chars_after_caret: 4,
107        };
108        let edit = plan_word_replacement(&at, "veneer").unwrap();
109        assert_eq!(edit.backspaces, 3);
110        assert_eq!(edit.deletes, 4);
111        assert_eq!(edit.insert, "veneer");
112    }
113
114    #[test]
115    fn count_is_in_characters_not_bytes() {
116        // "café" is 4 characters but 5 UTF-8 bytes; the emulation
117        // layer sends one Backspace / Delete per character.
118        let edit = plan_word_replacement(&word_at_end("café", " "), "coffee").unwrap();
119        assert_eq!(edit.backspaces, 5);
120        assert_eq!(edit.insert, "coffee ");
121    }
122}