diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index d9b3ee16..c8b91b90 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -7,7 +7,7 @@ mod macos; mod windows; pub mod worker; -use crate::scan_keyboard::{Keyboard, Modifier, Page, Stroke}; +use crate::scan_keyboard::{Key, Keyboard, Modifier, Page, Stroke}; use std::{ cell::RefCell, path::Path, @@ -17,7 +17,7 @@ use std::{ time::{Duration, Instant}, }; use tauri::{AppHandle, Manager}; -use worker::{Request, Response}; +use worker::{Edit, Request, Response}; #[cfg(target_os = "windows")] struct Job(windows_sys::Win32::Foundation::HANDLE); @@ -144,7 +144,7 @@ struct Service { generation: u64, outstanding: Option, last: Option, - edit: Option, + edit: Vec, reset: bool, accept: Option<(u64, usize)>, accepting: bool, @@ -152,6 +152,34 @@ struct Service { tracking: bool, } impl Service { + fn queue_edit(&mut self, edit: Edit) { + if self.edit.len() >= 512 { + self.edit.clear(); + self.reset = true; + } + self.edit.push(edit); + } + fn take_edits(&mut self) -> Vec { + let mut edits = std::mem::take(&mut self.edit); + if std::mem::take(&mut self.reset) { + edits.insert(0, Edit::Reset); + } + edits + } + + fn received_suggestions( + &mut self, + keyboard: &mut Keyboard, + generation: u64, + batch: Option, + tracking: bool, + ) { + if generation == self.generation { + self.suggestions(keyboard, batch, tracking); + } else { + self.reset = true; + } + } fn suggestions( &mut self, keyboard: &mut Keyboard, @@ -160,15 +188,17 @@ impl Service { ) { self.tracking = tracking; if !tracking { - self.edit = None; + self.edit.clear(); } - keyboard.predictions(batch, false); + let visible = keyboard.page == Page::Letters + && !keyboard.modifiers[1..].iter().any(|m| *m != Modifier::Off); + keyboard.predictions(if visible { batch } else { None }, false); } fn fail(&mut self, keyboard: &mut Keyboard) { self.client = None; self.failed = true; self.tracking = false; - self.edit = None; + self.edit.clear(); self.outstanding = None; keyboard.predictions(None, true); if self.accepting || self.accept.is_some() { @@ -188,19 +218,19 @@ pub fn record(stroke: Stroke, success: bool) { s.generation = s.generation.wrapping_add(1); s.last = None; if success && !stroke.shortcut() && s.tracking { - if let Some(c) = stroke.character() { - if s.edit - .as_ref() - .is_some_and(|text| text.chars().count() >= 512) - { - s.edit = None; - s.reset = true; - } - s.edit.get_or_insert_with(String::new).push(c); + let edit = stroke + .character() + .map(|c| Edit::Append(c.to_string())) + .or_else(|| { + (stroke.key == Key::Named("Backspace") && !stroke.modifiers[0]) + .then_some(Edit::Backspace) + }); + if let Some(edit) = edit { + s.queue_edit(edit); return; } } - s.edit = None; + s.edit.clear(); s.reset = true; }); } @@ -294,8 +324,8 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign generation, batch, tracking, - } if generation == s.generation => { - s.suggestions(keyboard, batch, tracking); + } => { + s.received_suggestions(keyboard, generation, batch, tracking); } Response::Insert { generation, text } => { s.accepting = false; @@ -303,8 +333,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign text.ok_or(()).and_then(|text| { crate::point_scan_ready(app).map_err(|_| ())?; crate::scan_executor::prediction_text(&text).map_err(|_| ())?; - s.edit = Some(text); - s.reset = false; + s.queue_edit(Edit::Append(text)); Ok(()) }) } else { @@ -321,7 +350,6 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign keyboard.failed(); } } - _ => {} } } Ok(Err(())) | Err(mpsc::TryRecvError::Disconnected) => { @@ -341,15 +369,6 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign index, } } else { - if keyboard.page != Page::Letters - || keyboard.modifiers[1..].iter().any(|m| *m != Modifier::Off) - { - keyboard.predictions(None, false); - s.tracking = false; - s.edit = None; - s.reset = true; - return; - } if s.last .is_some_and(|t| t.elapsed() < Duration::from_millis(250)) { @@ -357,8 +376,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign } Request::Query { generation: s.generation, - edit: s.edit.take(), - reset: std::mem::take(&mut s.reset), + edits: s.take_edits(), shift: keyboard.modifiers[0] != Modifier::Off, caps: keyboard.caps, } diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index f7380c28..c1cb8709 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -8,16 +8,23 @@ use std::{ io::{Read, Write}, path::PathBuf, }; +use unicode_segmentation::UnicodeSegmentation; pub const ARG: &str = "--switchify-prediction-worker"; pub const LIMIT: usize = 16384; +#[derive(Clone, Serialize, Deserialize)] +pub enum Edit { + Append(String), + Backspace, + Reset, +} + // Private inherited-pipe payloads. Never log these or forward them to Tauri. #[derive(Serialize, Deserialize)] pub enum Request { Query { generation: u64, - edit: Option, - reset: bool, + edits: Vec, shift: bool, caps: bool, }, @@ -73,6 +80,7 @@ pub struct Engine { snapshot: Option, fallback: String, boundary: bool, + fallback_clipped: bool, tracked: bool, activity: u64, observe: fn() -> (u64, bool), @@ -91,6 +99,7 @@ impl Engine { snapshot: None, fallback: String::new(), boundary: false, + fallback_clipped: true, activity: activity::snapshot().0, observe: activity::snapshot, batch: None, @@ -102,6 +111,7 @@ impl Engine { fn clear(&mut self) { self.fallback.clear(); self.boundary = false; + self.fallback_clipped = true; self.snapshot = None; self.batch = None; self.suffixes.clear(); @@ -118,6 +128,44 @@ impl Engine { .is_ok_and(|current| self.adapter.same(target, ¤t).unwrap_or(false)) && (self.observe)().0 == epoch } + fn trim_buffer(&mut self) { + while self.fallback.chars().count() > 512 { + let end = self.fallback.graphemes(true).next().unwrap().len(); + self.fallback.drain(..end); + self.fallback_clipped = true; + } + } + fn apply_edit(&mut self, edit: Edit) { + match edit { + Edit::Reset => self.clear(), + Edit::Append(text) => { + if text.chars().count() > 512 { + self.clear(); + return; + } + for c in text.chars() { + if c.is_whitespace() || matches!(c, '.' | '!' | '?' | ',' | ';' | ':') { + self.boundary = true; + } + if self.boundary { + self.fallback.push(c); + } + } + self.trim_buffer(); + } + Edit::Backspace => { + if let Some((start, _)) = self.fallback.grapheme_indices(true).next_back() { + self.fallback.truncate(start); + if self.fallback.is_empty() && self.fallback_clipped { + self.boundary = false; + } + } else { + self.clear(); + } + } + } + } + #[cfg(test)] fn query( &mut self, edit: Option, @@ -125,6 +173,16 @@ impl Engine { shift: bool, caps: bool, ) -> Option { + let mut edits = Vec::new(); + if reset { + edits.push(Edit::Reset); + } + if let Some(text) = edit { + edits.push(Edit::Append(text)); + } + self.query_edits(edits, shift, caps) + } + fn query_edits(&mut self, edits: Vec, shift: bool, caps: bool) -> Option { let target = match self.target() { Ok(t) => t, Err(_) => { @@ -139,7 +197,7 @@ impl Engine { .as_ref() .is_some_and(|old| self.adapter.same(old, &target).unwrap_or(false)); let uninterrupted = epoch == self.activity; - if !same || !uninterrupted || reset { + if !same || !uninterrupted { self.clear(); } if !healthy { @@ -150,28 +208,12 @@ impl Engine { } } self.activity = epoch; - if same && uninterrupted && !reset && self.tracked && healthy { - if let Some(text) = edit { - if text.chars().count() > 512 { - self.clear(); - } else { - for c in text.chars() { - if c.is_whitespace() || matches!(c, '.' | '!' | '?' | ',' | ';' | ':') { - self.boundary = true; - } - if self.boundary { - self.fallback.push(c); - } - } - self.fallback = self - .fallback - .chars() - .rev() - .take(512) - .collect::>() - .into_iter() - .rev() - .collect(); + if same && uninterrupted && self.tracked && healthy { + if edits.len() > 512 { + self.clear(); + } else { + for edit in edits { + self.apply_edit(edit); } } } @@ -183,7 +225,7 @@ impl Engine { RawContext { before: self.fallback.clone(), after: String::new(), - clipped_start: true, + clipped_start: self.fallback_clipped, has_selection: false, position: -1, } @@ -198,6 +240,12 @@ impl Engine { self.clear(); return None; } + if raw.position != -1 && self.tracked && healthy && self.adapter.editable(&target) { + self.fallback = raw.before.clone(); + self.fallback_clipped = raw.clipped_start; + self.boundary = true; + self.trim_buffer(); + } self.target = Some(target); if self.snapshot.as_ref() == Some(&raw) && self.case == (shift, caps) { return self.batch.clone(); @@ -286,12 +334,11 @@ impl Engine { match request { Request::Query { generation, - edit, - reset, + edits, shift, caps, } => { - let mut batch = self.query(edit, reset, shift, caps); + let mut batch = self.query_edits(edits, shift, caps); let current = self.target(); let stable = current.as_ref().is_ok_and(|current| { self.target @@ -431,6 +478,161 @@ mod tests { e.activity = 0; e } + + #[test] + fn stale_native_reads_cannot_double_apply_later_typing_or_backspace() { + for deletion in [false, true] { + let mut e = engine(); + e.query(None, false, false, false); + let mut service = crate::prediction::Service { + generation: 1, + tracking: true, + ..Default::default() + }; + let mut keyboard = crate::scan_keyboard::Keyboard::new(false); + e.adapter.raw.before = if deletion { "I like w" } else { "I like wat" }.into(); + let response = e.respond(Request::Query { + generation: 0, + edits: vec![], + shift: false, + caps: false, + }); + service.queue_edit(if deletion { + Edit::Backspace + } else { + Edit::Append("t".into()) + }); + if let Response::Suggestions { + generation, + batch, + tracking, + } = response + { + service.received_suggestions(&mut keyboard, generation, batch, tracking); + } else { + panic!("expected suggestions"); + } + e.adapter.unsupported = true; + assert!(e.query_edits(service.take_edits(), false, false).is_none()); + assert!(e.fallback.is_empty()); + assert!(!e.boundary); + assert!(e + .query_edits(vec![Edit::Append(" wa".into())], false, false) + .is_some()); + } + } + #[test] + fn buffered_edits_preserve_order_unicode_and_boundary_confidence() { + let mut e = engine(); + e.adapter.unsupported = true; + e.query(None, false, false, false); + let b = e + .query_edits( + vec![Edit::Append(" waX".into()), Edit::Backspace], + false, + false, + ) + .unwrap(); + assert_eq!(b.words[0], "water"); + assert_eq!(e.fallback, " wa"); + e.query_edits( + vec![ + Edit::Append("e\u{301}👩‍💻".into()), + Edit::Backspace, + Edit::Backspace, + ], + false, + false, + ); + assert_eq!(e.fallback, " wa"); + e.query_edits( + vec![ + Edit::Backspace, + Edit::Backspace, + Edit::Backspace, + Edit::Backspace, + Edit::Append("wa".into()), + ], + false, + false, + ); + assert!(!e.boundary); + assert!(e.query(None, false, false, false).is_none()); + assert!(e.query(Some(" wa".into()), false, false, false).is_some()); + } + #[test] + fn native_context_seeds_fallback_and_recovers_authoritatively() { + let mut e = engine(); + e.query(None, false, false, false).unwrap(); + e.adapter.unsupported = true; + let b = e + .query_edits(vec![Edit::Append("t".into())], false, false) + .unwrap(); + assert_eq!(e.fallback, "I like wat"); + let suffix = e.accept(b.token, 0).unwrap(); + assert_eq!(suffix, "er "); + assert!(e.accept(b.token, 0).is_none()); + e.query_edits(vec![Edit::Append(suffix)], false, false); + assert_eq!(e.fallback, "I like water "); + e.adapter.unsupported = false; + e.adapter.raw.before = "wa".into(); + let recovered = e.query(None, false, false, false).unwrap(); + assert_eq!(e.fallback, "wa"); + assert!(e.accept(b.token, 0).is_none()); + assert_eq!(e.accept(recovered.token, 0), Some("ter ".into())); + } + #[test] + fn reset_then_new_typing_does_not_reuse_old_context() { + let mut e = engine(); + e.query(None, false, false, false); + e.adapter.unsupported = true; + e.query_edits(vec![Edit::Reset, Edit::Append("wa".into())], false, false); + assert!(!e.boundary); + let b = e + .query_edits(vec![Edit::Append(" wa".into())], false, false) + .unwrap(); + for _ in 0..40 { + assert_eq!(e.query(None, false, false, false).unwrap().token, b.token); + } + e.adapter.target += 1; + assert!(e + .query_edits(vec![Edit::Append("ter ".into())], false, false) + .is_none()); + assert!(e.fallback.is_empty()); + } + #[test] + fn buffer_and_edit_queue_are_bounded() { + let mut e = engine(); + e.query(None, false, false, false); + e.adapter.unsupported = true; + for _ in 0..8 { + e.query_edits(vec![Edit::Append(" word".repeat(90))], false, false); + } + assert!(e.fallback.chars().count() <= 512); + assert!(e.fallback_clipped); + e.query_edits(vec![Edit::Backspace; 513], false, false); + assert!(e.fallback.is_empty()); + let mut service = crate::prediction::Service::default(); + for _ in 0..513 { + service.queue_edit(Edit::Append("a".into())); + } + let edits = service.take_edits(); + assert!(matches!(edits[0], Edit::Reset)); + assert_eq!(edits.len(), 2); + } + #[test] + fn layouts_hide_suggestions_without_discarding_buffer_tracking() { + let mut service = crate::prediction::Service::default(); + let mut keyboard = crate::scan_keyboard::Keyboard::new(false); + keyboard.page = crate::scan_keyboard::Page::Numbers; + service.suggestions(&mut keyboard, None, true); + assert!(service.tracking); + service.queue_edit(Edit::Append("123".into())); + keyboard.page = crate::scan_keyboard::Page::Letters; + service.suggestions(&mut keyboard, None, true); + assert!(!service.reset); + assert_eq!(service.take_edits().len(), 1); + } #[test] fn native_predictions_survive_polls_without_fallback_tracking() { for unavailable in 0..3 { @@ -447,8 +649,7 @@ mod tests { for _ in 0..40 { let response = e.respond(Request::Query { generation: 0, - edit: service.edit.take(), - reset: std::mem::take(&mut service.reset), + edits: service.take_edits(), shift: false, caps: false, }); @@ -518,12 +719,12 @@ mod tests { fn losing_tracking_does_not_erase_a_pending_context_reset() { let mut service = crate::prediction::Service { reset: true, - edit: Some("queued".into()), + edit: vec![Edit::Append("queued".into())], ..Default::default() }; service.suggestions(&mut crate::scan_keyboard::Keyboard::new(false), None, false); assert!(service.reset); - assert!(service.edit.is_none()); + assert!(service.edit.is_empty()); } #[test] fn only_suffix_and_space_are_returned_once() { @@ -593,8 +794,7 @@ mod tests { assert!(matches!( e.respond(Request::Query { generation: 1, - edit: None, - reset: false, + edits: vec![], shift: false, caps: false, }), @@ -622,8 +822,7 @@ mod tests { assert!(receive::(&mut &b"bad!"[..]).is_err()); let request = Request::Query { generation: 1, - edit: Some("x".repeat(LIMIT)), - reset: false, + edits: vec![Edit::Append("x".repeat(LIMIT))], shift: false, caps: false, };