Skip to content
Merged
160 changes: 160 additions & 0 deletions crates/cc-app/src/fixgate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const UNDO_LIMIT: usize = 2;
pub const PAUSE: Duration = Duration::from_secs(60);
/// Characters compared by [`same_tail`].
const TAIL: usize = 32;
/// Characters the field may lag behind the shadow and still count as [`Tail::Behind`].
const LAG: usize = 8;

/// Keys that end a word for auto-fix. An apostrophe or hyphen continues the word
/// (`doesn't`, `e-mail`): fixing `doesn` at the apostrophe leaves `does't`.
Expand Down Expand Up @@ -90,6 +92,106 @@ pub fn same_tail(field: &str, shadow: &str) -> bool {
field.ends_with(&shadow[shadow.len().saturating_sub(TAIL)..])
}

/// Field text as UIA gives it, with characters nobody typed turned into spaces one for one,
/// so lengths still match the shadow: U+FFFC stands for an embedded object (an image, a
/// mention chip), and browsers keep a typed space as a no-break space.
pub fn field_text(raw: &str) -> String {
raw.chars().map(|c| if matches!(c, '\u{FFFC}' | '\u{A0}' | '\u{202F}') { ' ' } else { c }).collect()
}

/// Whether `field`, the text before the caret read just before a fix is typed, ends with
/// `delete`, the text the fix will remove, and has a word break before it. The last
/// character of `delete` is the key that triggered the fix; it may not have reached the
/// field yet, and will before the fix does, since input arrives in order.
pub fn ready_for(field: &str, delete: &str) -> bool {
let fold = |c: char| match c {
'\u{2018}' | '\u{2019}' => '\'',
'\u{201C}' | '\u{201D}' => '"',
c if c.is_whitespace() => ' ',
c => c.to_lowercase().next().unwrap_or(c),
};
let field: Vec<char> = field_text(field).chars().map(fold).collect();
let delete: Vec<char> = delete.chars().map(fold).collect();
let fits = |n: usize| {
n <= field.len()
&& field[field.len() - n..] == delete[..n]
&& field[..field.len() - n].last().is_none_or(|c| !c.is_alphanumeric())
};
!delete.is_empty() && (fits(delete.len()) || (delete.len() > 1 && fits(delete.len() - 1)))
}

/// The dictionary word close to `typo` that the language model clearly prefers over `fix`,
/// given the model's likeliest next tokens (`top`) after the text before the typo: its
/// token is at least four times as likely as any token `fix` starts with. `this morth`
/// gives `month`, where the typing model alone picks `north`.
pub fn model_prefers(typo: &str, fix: &str, top: &[(String, f32)], is_word: impl Fn(&str) -> bool) -> Option<String> {
let typo = typo.to_lowercase();
let fix = fix.to_lowercase();
let reach = (typo.chars().count() / 3).max(2);
let mut fix_lp = f32::NEG_INFINITY;
let mut other: Option<(String, f32)> = None;
for (token, lp) in top {
let word = token.trim().to_lowercase();
if word.is_empty() || !word.chars().all(char::is_alphabetic) {
continue;
}
if fix.starts_with(&word) && (word.chars().count() >= 2 || word == fix) {
fix_lp = fix_lp.max(*lp);
} else if word.chars().count() >= 2
&& cc_correct::edit_distance(&typo, &word) <= reach
&& is_word(&word)
&& other.as_ref().is_none_or(|(_, best)| lp > best)
{
other = Some((word, *lp));
}
}
let (word, lp) = other?;
(lp - fix_lp >= 4f32.ln()).then_some(word)
}

/// The end of `s` with letters written as `a` or `A` and digits as `0`, for the log: it
/// shows a joined or doubled word without showing what was typed.
pub fn shape(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
chars[chars.len().saturating_sub(TAIL)..]
.iter()
.map(|&c| match c {
c if c.is_uppercase() => 'A',
c if c.is_alphabetic() => 'a',
c if c.is_numeric() => '0',
c => c,
})
.collect()
}

/// How the end of the field's text relates to the end of the shadow buffer.
#[derive(Debug, PartialEq, Eq)]
pub enum Tail {
Same,
/// The field is the shadow without its last few characters: UIA has not caught up.
Behind,
/// Something changed the text without the shadow seeing it.
Different,
}

pub fn compare(field: &str, shadow: &str) -> Tail {
if same_tail(field, shadow) {
return Tail::Same;
}
let chars: Vec<char> = shadow.chars().collect();
for k in 1..=LAG.min(chars.len()) {
let older: String = chars[..chars.len() - k].iter().collect();
// Too little left to tell a lagging field from a different one.
if comparable(&older).len() < TAIL / 2 {
break;
}
if same_tail(field, &older) {
return Tail::Behind;
}
}
Tail::Different
}

fn comparable(s: &str) -> Vec<char> {
let mut out = Vec::new();
for c in s.trim_end().chars() {
Expand Down Expand Up @@ -295,6 +397,64 @@ mod tests {
assert!(!same_tail("previousthe ", "previous the "));
}

#[test]
fn field_text_keeps_lengths_and_drops_what_was_never_typed() {
assert_eq!(field_text("a\u{FFFC}b\u{A0}c"), "a b c");
assert_eq!(field_text("plain").chars().count(), 5);
}

#[test]
fn compare_tells_a_lagging_field_from_a_changed_one() {
let shadow = "the quick brown fox jumps over the lazy dog";
assert_eq!(compare("Once more: the quick brown fox jumps over the lazy dog", shadow), Tail::Same);
assert_eq!(compare("the quick brown fox jumps over the lazy d", shadow), Tail::Behind);
assert_eq!(compare("the quick brown fox jumps over the lazydog", shadow), Tail::Different);
assert_eq!(compare("something else entirely", shadow), Tail::Different);
assert_eq!(compare("anything", ""), Tail::Same, "an empty shadow knows nothing");
assert_eq!(compare("ab", "abc"), Tail::Different, "too short to call it lag");
}

#[test]
fn ready_for_needs_the_whole_word_before_the_caret() {
assert!(ready_for("I want teh ", "teh "));
assert!(ready_for("I want teh", "teh "), "the space may still be on its way");
assert!(ready_for("teh ", "teh "), "at the start of the field");
assert!(ready_for("(Teh\u{a0}", "teh "));
assert!(!ready_for("I want teh ", "tehh "), "the shadow counted a letter twice");
assert!(!ready_for("I want xteh ", "teh "), "part of a longer word");
assert!(!ready_for("I want teh t", "teh "), "a key got typed after the space");
assert!(!ready_for("I want te", "teh "));
assert!(!ready_for("anything", ""));
}

#[test]
fn model_prefers_a_close_word_it_rates_far_higher() {
let top = |pairs: &[(&str, f32)]| pairs.iter().map(|(t, lp)| (t.to_string(), *lp)).collect::<Vec<_>>();
let words = |w: &str| ["month", "north", "week", "the", "ten", "calorie", "cal"].contains(&w);
assert_eq!(
model_prefers("morth", "north", &top(&[(" month", -0.7), (" week", -1.9)]), words).as_deref(),
Some("month")
);
assert_eq!(model_prefers("teh", "the", &top(&[(" the", -0.2), (" ten", -3.0)]), words), None);
assert_eq!(
model_prefers("calory", "calorie", &top(&[(" cal", -0.5), (" month", -0.4)]), words),
None,
"a token the fix starts with counts for the fix, and month is too far from the typo"
);
assert_eq!(model_prefers("morth", "north", &top(&[(" mxrth", -0.1)]), words), None, "not a word");
assert_eq!(
model_prefers("morth", "north", &top(&[(" north", -1.0), (" month", -0.5)]), words),
None,
"not four times as likely"
);
}

#[test]
fn shape_hides_letters_and_digits_only() {
assert_eq!(shape("Previous the 2nd, ok."), "Aaaaaaaa aaa 0aa, aa.");
assert_eq!(shape(&"x".repeat(40)).len(), TAIL);
}

#[test]
fn breaker_trips_on_two_quick_undos_in_one_window() {
let mut b = Breaker::default();
Expand Down
Loading
Loading