diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 4df96e85..05342571 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -130,6 +130,22 @@ impl Adapter for PointScan { crate::prediction::record(stroke, result.is_ok(), scope); return result.map(|()| None); } + Request::KeyboardPunctuation(mut punctuation) => { + let scope = crate::prediction::InputScope::capture(); + if punctuation.owned_space != crate::prediction::keyboard_input_context() { + punctuation.owned_space = None; + } + let removed_space = punctuation.owned_space.is_some(); + let result = + crate::scan_executor::activate(Request::KeyboardPunctuation(punctuation)); + crate::prediction::record_punctuation( + punctuation.mark, + removed_space, + result.is_ok(), + scope, + ); + return result.map(|()| None); + } Request::OpenKeyboard | Request::OpenMouse | Request::OpenPoint => { crate::prediction::stop(); crate::scan_executor::activate(request) @@ -246,12 +262,16 @@ impl Adapter for PointScan { | Request::MouseMove { .. } | Request::MouseMoveAbsolute { .. } | Request::MouseScroll { .. } + | Request::KeyboardPunctuation(_) ) } fn deferred(request: &Request) -> bool { matches!(request, Request::Prediction { .. }) } fn poll(app: &AppHandle, technique: &mut Workflow, captured_keys: &[String]) { + if technique.keyboard_open() { + crate::prediction::start_keyboard_activity(captured_keys); + } let enabled = technique.prediction_enabled(); crate::prediction::poll(app, technique.prediction_keyboard(), enabled, captured_keys); } diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 60f11566..b460d91b 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -16,6 +16,7 @@ pub enum Request { OpenMouse, OpenPoint, Keyboard(crate::scan_keyboard::Stroke), + KeyboardPunctuation(crate::scan_keyboard::Punctuation), MouseMove { dx: i32, dy: i32, @@ -536,7 +537,8 @@ impl Technique for Workflow { if self.stage == Stage::KeyboardOpening { self.stage = Stage::Keyboard; } else if self.stage == Stage::Keyboard { - self.keyboard.succeeded(); + self.keyboard + .succeeded_with_context(crate::prediction::keyboard_input_context()); } } fn start(&mut self) { @@ -615,10 +617,16 @@ impl Technique for Workflow { return self.selected(Item::Keyboard); } match self.stage { - Stage::Keyboard => match self.keyboard.handle(action) { + Stage::Keyboard => match self + .keyboard + .handle_with_context(action, crate::prediction::keyboard_input_context()) + { Some(crate::scan_keyboard::Output::Stroke(stroke)) => { return Some(Request::Keyboard(stroke)) } + Some(crate::scan_keyboard::Output::Punctuation(punctuation)) => { + return Some(Request::KeyboardPunctuation(punctuation)) + } Some(crate::scan_keyboard::Output::Prediction { token, index }) => { return Some(Request::Prediction { token, index }) } diff --git a/src-tauri/src/prediction/activity.rs b/src-tauri/src/prediction/activity.rs index d8b5adcc..a7871bcf 100644 --- a/src-tauri/src/prediction/activity.rs +++ b/src-tauri/src/prediction/activity.rs @@ -1,7 +1,37 @@ //! A content-free input epoch. Never suppresses or records external input. use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{OnceLock, RwLock}; static EPOCH: AtomicU64 = AtomicU64::new(0); static HEALTHY: AtomicBool = AtomicBool::new(false); +static IGNORED: OnceLock>> = OnceLock::new(); +fn ignored() -> &'static RwLock> { + IGNORED.get_or_init(|| RwLock::new(Vec::new())) +} +pub fn set_ignored(mut keys: Vec) { + keys.sort_unstable(); + keys.dedup(); + let mut current = ignored().write().unwrap_or_else(|error| error.into_inner()); + if *current != keys { + *current = keys; + // An assignment change makes earlier ownership claims unsafe. + changed(); + } +} +fn ignored_key(code: u32) -> bool { + ignored() + .read() + .unwrap_or_else(|error| error.into_inner()) + .binary_search(&code) + .is_ok() +} +#[cfg(any(target_os = "macos", test))] +fn first_disable(flag: &AtomicBool) -> bool { + !flag.swap(true, Ordering::SeqCst) +} +#[cfg(any(target_os = "macos", test))] +fn needs_exit_invalidation(flag: &AtomicBool) -> bool { + !flag.load(Ordering::SeqCst) +} pub fn snapshot() -> (u64, bool) { (EPOCH.load(Ordering::SeqCst), HEALTHY.load(Ordering::SeqCst)) } @@ -24,16 +54,12 @@ fn changed() { } #[cfg(target_os = "windows")] -pub fn start(ignored: Vec) -> bool { - use std::cell::RefCell; +pub fn start() -> bool { use windows_sys::Win32::{System::LibraryLoader::GetModuleHandleW, UI::WindowsAndMessaging::*}; - thread_local! { static IGNORE: RefCell> = const { RefCell::new(Vec::new()) }; } unsafe extern "system" fn keyboard(code: i32, wp: usize, lp: isize) -> isize { if code >= 0 && matches!(wp as u32, WM_KEYDOWN | WM_SYSKEYDOWN) { let key = unsafe { &*(lp as *const KBDLLHOOKSTRUCT) }; - if !crate::input::own_input(key.dwExtraInfo as i64) - && !IGNORE.with(|i| i.borrow().contains(&key.vkCode)) - { + if !crate::input::own_input(key.dwExtraInfo as i64) && !ignored_key(key.vkCode) { changed(); } } @@ -55,7 +81,6 @@ pub fn start(ignored: Vec) -> bool { } let (tx, rx) = std::sync::mpsc::sync_channel(1); std::thread::spawn(move || unsafe { - IGNORE.with(|i| *i.borrow_mut() = ignored); let k = SetWindowsHookExW( WH_KEYBOARD_LL, Some(keyboard), @@ -92,11 +117,13 @@ pub fn start(ignored: Vec) -> bool { .unwrap_or(false) } #[cfg(target_os = "macos")] -pub fn start(ignored: Vec) -> bool { +pub fn start() -> bool { use core_graphics::event::{ CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, CallbackResult, }; + let disabled = std::sync::Arc::new(AtomicBool::new(false)); + let callback_disabled = disabled.clone(); let (tx, rx) = std::sync::mpsc::sync_channel(1); std::thread::spawn(move || { let ready = tx.clone(); @@ -116,12 +143,16 @@ pub fn start(ignored: Vec) -> bool { kind, CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput ) { - unavailable(); + if first_disable(&callback_disabled) { + unavailable(); + } + // Returning from the run loop drops this tap before a retry. + core_foundation::runloop::CFRunLoop::get_current().stop(); return CallbackResult::Keep; } if !crate::input::own_input(event.get_integer_value_field(42)) && (!matches!(kind, CGEventType::KeyDown) - || !ignored.contains(&(event.get_integer_value_field(9) as u32))) + || !ignored_key(event.get_integer_value_field(9) as u32)) { changed(); } @@ -135,12 +166,40 @@ pub fn start(ignored: Vec) -> bool { }, ); let _ = tx.send(false); - unavailable(); + if needs_exit_invalidation(&disabled) { + unavailable(); + } }); rx.recv_timeout(std::time::Duration::from_millis(500)) .unwrap_or(false) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] -pub fn start(_: Vec) -> bool { +pub fn start() -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reassignment_updates_ignored_keys_and_invalidates_old_context() { + set_ignored(vec![32]); + assert!(ignored_key(32)); + let before = snapshot().0; + set_ignored(vec![120]); + assert!(snapshot().0 > before); + assert!(!ignored_key(32)); + assert!(ignored_key(120)); + set_ignored(Vec::new()); + } + + #[test] + fn disabled_tap_invalidates_once_before_its_run_loop_exits() { + let disabled = AtomicBool::new(false); + assert!(needs_exit_invalidation(&disabled)); + assert!(first_disable(&disabled)); + assert!(!first_disable(&disabled)); + assert!(!needs_exit_invalidation(&disabled)); + } +} diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index e57ab203..91569cc5 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -11,6 +11,7 @@ use std::{ cell::RefCell, path::Path, process::{Child, ChildStdin, Command, Stdio}, + sync::atomic::{AtomicU8, Ordering}, sync::mpsc, thread::JoinHandle, time::{Duration, Instant}, @@ -259,6 +260,49 @@ impl Service { } } thread_local! { static SERVICE: RefCell = RefCell::new(Service::default()); } +// 0 = idle, 1 = starting, 2 = running. A failed or disabled hook can retry. +static KEYBOARD_ACTIVITY: AtomicU8 = AtomicU8::new(0); + +fn claim_keyboard_activity_start(state: &AtomicU8, healthy: bool) -> bool { + if healthy { + return false; + } + let _ = state.compare_exchange(2, 0, Ordering::SeqCst, Ordering::SeqCst); + state + .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() +} + +pub fn start_keyboard_activity(ignored: &[String]) { + activity::set_ignored( + ignored + .iter() + .filter_map(|name| crate::switch_input::prediction_key_code(name)) + .collect(), + ); + if claim_keyboard_activity_start(&KEYBOARD_ACTIVITY, activity::snapshot().1) { + std::thread::spawn(move || { + let started = activity::start(); + if !started { + // Avoid a busy retry loop while native input access is unavailable. + std::thread::sleep(Duration::from_secs(1)); + } + KEYBOARD_ACTIVITY.store(if started { 2 } else { 0 }, Ordering::SeqCst); + }); + } +} + +pub fn keyboard_input_context() -> Option { + let (epoch, healthy) = activity::snapshot(); + if !healthy { + return None; + } + let foreground = crate::scan_host::foreground().ok()?; + (foreground != 0).then_some(crate::scan_keyboard::TypingContext { + foreground, + activity: epoch, + }) +} pub fn stop() { SERVICE.with(|s| *s.borrow_mut() = Service::default()); } @@ -284,6 +328,22 @@ pub fn record(stroke: Stroke, success: bool, scope: InputScope) { s.reset = true; }); } +pub fn record_punctuation(mark: char, removed_space: bool, success: bool, scope: InputScope) { + SERVICE.with(|s| { + let mut s = s.borrow_mut(); + s.generation = s.generation.wrapping_add(1); + s.last = None; + if success && scope.unchanged() { + if removed_space { + s.queue_edit(Edit::Backspace, scope); + } + s.queue_edit(Edit::Append(format!("{mark} ")), scope); + } else { + s.edit.clear(); + s.reset = true; + } + }); +} pub fn select(token: u64, index: usize) -> Result<(), String> { SERVICE.with(|s| { let mut s = s.borrow_mut(); @@ -331,7 +391,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign SERVICE.with(|slot| { let mut s = slot.borrow_mut(); let case = ( - keyboard.modifiers[0] != Modifier::Off, + keyboard.prediction_shift(), keyboard.caps, keyboard.modifiers[1..].iter().any(|m| *m != Modifier::Off), ); @@ -395,8 +455,10 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign if !scope.unchanged() { return Err(()); } + let trailing_space = text.ends_with(' '); + let contains_letter = text.chars().any(char::is_alphabetic); s.queue_edit(Edit::Append(text), scope); - Ok(()) + Ok((trailing_space, contains_letter)) }) } else { Err(()) @@ -404,8 +466,14 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign s.generation = s.generation.wrapping_add(1); s.last = None; keyboard.predictions(None, false); - if result.is_ok() { - keyboard.succeeded(); + if let Ok((trailing_space, contains_letter)) = result { + if keyboard.succeeded() { + keyboard.prediction_inserted( + trailing_space, + contains_letter, + keyboard_input_context(), + ); + } } else { s.edit.clear(); s.reset = true; @@ -442,7 +510,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign generation: s.generation, edits: s.take_edits(), revision: s.edit_revision, - shift: keyboard.modifiers[0] != Modifier::Off, + shift: keyboard.prediction_shift(), caps: keyboard.caps, } }; @@ -459,6 +527,35 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign mod tests { use super::*; #[test] + fn activity_observer_retries_after_failure_or_lost_hook() { + let state = AtomicU8::new(0); + assert!(claim_keyboard_activity_start(&state, false)); + assert!(!claim_keyboard_activity_start(&state, false)); + state.store(0, Ordering::SeqCst); // first hook attempt failed + assert!(claim_keyboard_activity_start(&state, false)); + state.store(2, Ordering::SeqCst); // second hook started + assert!(!claim_keyboard_activity_start(&state, true)); + assert!(claim_keyboard_activity_start(&state, false)); // hook later stopped + } + #[test] + fn punctuation_records_the_backspace_and_insert_as_one_successful_edit() { + stop(); + record_punctuation('.', true, true, InputScope::capture()); + SERVICE.with(|slot| { + let service = slot.borrow(); + assert_eq!(service.edit.len(), 2); + assert!(matches!(service.edit[0].edit, Edit::Backspace)); + assert!(matches!(service.edit[1].edit, Edit::Append(ref text) if text == ". ")); + }); + record_punctuation('?', true, false, InputScope::capture()); + SERVICE.with(|slot| { + let service = slot.borrow(); + assert!(service.edit.is_empty()); + assert!(service.reset); + }); + stop(); + } + #[test] fn reopening_discards_failed_workers_and_pending_acceptance() { for failed in [false, true] { SERVICE.with(|slot| { diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 00e43b85..d6dba19d 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -320,7 +320,8 @@ pub fn run_from_args() -> bool { } }); } - let tracked = activity::start(ignored); + activity::set_ignored(ignored); + let tracked = activity::start(); #[cfg(any(target_os = "windows", target_os = "macos"))] { let database = Database::open(&path)?; @@ -457,6 +458,14 @@ mod tests { assert_ne!(upper.token, b.token); } #[test] + fn sentence_start_prediction_uses_title_case_without_manual_shift() { + let mut e = engine(); + let suggestions = e.query(vec![append("Done! ")], 1, false, false).unwrap(); + assert_eq!(suggestions.words[0], "Water"); + let manual_shift = e.query(vec![], 1, true, false).unwrap(); + assert_eq!(manual_shift.words[0], "WATER"); + } + #[test] fn private_frames_are_bounded() { assert!(receive::(&mut &b"bad!"[..]).is_err()); let request = Request::Query { diff --git a/src-tauri/src/scan_executor.rs b/src-tauri/src/scan_executor.rs index 8b74ca7c..08a54431 100644 --- a/src-tauri/src/scan_executor.rs +++ b/src-tauri/src/scan_executor.rs @@ -75,6 +75,16 @@ pub fn execute( keys.push(&name); input.scan_chord(&keys, None) } + Request::KeyboardPunctuation(punctuation) => { + if input.has_active_drag() { + return Err("End the active drag before typing.".into()); + } + input.release_all()?; + if punctuation.owned_space.is_some() { + input.scan_chord(&["Backspace"], None)?; + } + input.type_text(&format!("{} ", punctuation.mark)) + } Request::Command { command, point } => execute_command(input, command, point), Request::Setting(_) | Request::Display(_) => { Err("Scanning action requires the scan controller.".into()) @@ -317,6 +327,7 @@ mod tests { fail_release: bool, fail_click: bool, fail_move: bool, + fail_text: bool, cursor: (i32, i32), target_clicks: Vec, legacy_clicks: usize, @@ -324,7 +335,11 @@ mod tests { impl InputInjector for Fake { fn inject_text(&mut self, text: &str) -> Result<(), String> { self.events.push(format!("text {text}")); - Ok(()) + if self.fail_text { + Err("text failed".into()) + } else { + Ok(()) + } } fn move_pointer(&mut self, dx: i32, dy: i32) -> Result<(), String> { self.events.push(format!("relative {dx} {dy}")); @@ -497,6 +512,56 @@ mod tests { ); } #[test] + fn smart_punctuation_edits_only_an_owned_space_and_cleans_up_failures() { + use crate::scan_keyboard::{Punctuation, TypingContext}; + let owned = Some(TypingContext { + foreground: 1, + activity: 2, + }); + let mut input = DesktopInput::new(Fake::default()); + execute( + &mut input, + Request::KeyboardPunctuation(Punctuation { + mark: '.', + owned_space: owned, + }), + true, + ) + .unwrap(); + assert_eq!( + input.injector.events, + ["key Backspace true", "key Backspace false", "text . "] + ); + input.injector.events.clear(); + execute( + &mut input, + Request::KeyboardPunctuation(Punctuation { + mark: ',', + owned_space: None, + }), + true, + ) + .unwrap(); + assert_eq!(input.injector.events, ["text , "]); + input.injector.events.clear(); + input.injector.fail_text = true; + assert!(execute( + &mut input, + Request::KeyboardPunctuation(Punctuation { + mark: '?', + owned_space: owned + }), + true + ) + .is_err()); + assert_eq!( + input.injector.events, + ["key Backspace true", "key Backspace false", "text ? "] + ); + input.injector.fail_text = false; + input.release_all().unwrap(); + } + #[test] fn numeric_operator_shortcuts_include_required_shift_and_release_every_key() { use crate::scan_keyboard::{Key, Stroke}; for (operator, native_key) in [('+', "="), ('*', "8")] { diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index dfdca2be..341963fe 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -50,6 +50,24 @@ pub struct Stroke { pub modifiers: [bool; 4], pub caps: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TypingContext { + pub foreground: usize, + pub activity: u64, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Punctuation { + pub mark: char, + pub owned_space: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingTyped { + Character(char), + Punctuation(char), + Navigation, + Other, + Prediction, +} impl Stroke { pub fn character(self) -> Option { let Key::Character(base, shifted) = self.key else { @@ -71,6 +89,7 @@ impl Stroke { pub enum Output { Prediction { token: u64, index: usize }, Stroke(Stroke), + Punctuation(Punctuation), Close, } @@ -185,6 +204,11 @@ pub struct Keyboard { prefer_predictions: bool, wait_after_typing: bool, waiting_after_typing: bool, + owned_space: Option, + capitalize_next: bool, + auto_shift: bool, + capital_context: Option, + pending_typed: Option, } impl Keyboard { #[cfg(test)] @@ -211,6 +235,11 @@ impl Keyboard { prefer_predictions: true, wait_after_typing: false, waiting_after_typing: false, + owned_space: None, + capitalize_next: false, + auto_shift: false, + capital_context: None, + pending_typed: None, } } pub fn with_wait_after_typing(mut self, enabled: bool) -> Self { @@ -313,7 +342,16 @@ impl Keyboard { self.skip_disabled(true); } } + #[cfg(test)] pub fn handle(&mut self, action: Action) -> Option { + self.handle_with_context(action, None) + } + pub fn handle_with_context( + &mut self, + action: Action, + context: Option, + ) -> Option { + self.discard_stale_context(context); if self.scan.pending() { return None; } @@ -331,12 +369,60 @@ impl Keyboard { return None; } if let Some(key) = self.scan.handle(action) { - return self.choose(key); + return self.choose_with_context(key, context); } self.skip_disabled(false); None } + #[cfg(test)] fn choose(&mut self, key: Key) -> Option { + self.choose_with_context(key, None) + } + fn stroke(&self, key: Key) -> Stroke { + let mut modifiers = self.modifiers.map(|m| m != Modifier::Off); + if self.auto_shift + && (!matches!(key, Key::Character(base, _) if base.is_ascii_alphabetic()) + || modifiers[1..].iter().any(|modifier| *modifier)) + { + modifiers[0] = false; + } + Stroke { + key, + modifiers, + caps: self.caps, + } + } + pub fn prediction_shift(&self) -> bool { + self.modifiers[0] != Modifier::Off && !self.auto_shift + } + fn clear_auto_capital(&mut self) { + if self.auto_shift { + self.modifiers[0] = Modifier::Off; + self.auto_shift = false; + } + self.capitalize_next = false; + self.capital_context = None; + } + fn reconcile_auto_shift(&mut self) { + if self.auto_shift { + self.modifiers[0] = Modifier::Off; + self.auto_shift = false; + } + if self.capitalize_next && !self.caps && self.modifiers[0] == Modifier::Off { + self.modifiers[0] = Modifier::Once; + self.auto_shift = true; + } + } + fn smart_mark(&self, stroke: Stroke) -> Option { + if stroke.shortcut() { + return None; + } + let mark = stroke.character()?; + let supported = matches!(mark, '.' | ',' | '!' | '?' | ':' | ';'); + (supported && (self.page == Page::Letters || mark == '!')).then_some(mark) + } + fn choose_with_context(&mut self, key: Key, context: Option) -> Option { + self.discard_stale_context(context); match key { Key::Prediction(index) => { let batch = self.predictions.as_ref()?; @@ -345,13 +431,27 @@ impl Keyboard { } self.activation = self.scan.begin_activation(); self.activation?; + self.pending_typed = Some(PendingTyped::Prediction); return Some(Output::Prediction { token: batch.token, index, }); } - Key::Modifier(i) => self.modifiers[i] = self.modifiers[i].next(), - Key::Caps => self.caps = !self.caps, + Key::Modifier(i) => { + if i == 0 && self.capitalize_next { + let automatic = self.auto_shift; + self.clear_auto_capital(); + if !automatic { + self.modifiers[i] = self.modifiers[i].next(); + } + } else { + self.modifiers[i] = self.modifiers[i].next(); + } + } + Key::Caps => { + self.caps = !self.caps; + self.reconcile_auto_shift(); + } Key::Dock => self.top = !self.top, Key::Page(page) => { self.page = page; @@ -359,31 +459,87 @@ impl Keyboard { } Key::Close => return Some(Output::Close), Key::Character(..) | Key::Named(_) => { + let stroke = self.stroke(key); + if stroke.character() == Some(' ') + && self.owned_space.is_some() + && self.owned_space == context + { + self.restart(); + return None; + } self.predictions = None; self.queued_predictions = None; self.activation = self.scan.begin_activation(); self.activation?; - return Some(Output::Stroke(Stroke { - key, - modifiers: self.modifiers.map(|m| m != Modifier::Off), - caps: self.caps, - })); + if let Some(mark) = self.smart_mark(stroke) { + self.pending_typed = Some(PendingTyped::Punctuation(mark)); + return Some(Output::Punctuation(Punctuation { + mark, + owned_space: self.owned_space.filter(|saved| Some(*saved) == context), + })); + } + self.pending_typed = Some(if matches!(stroke.key, Key::Named(_)) { + PendingTyped::Navigation + } else { + stroke + .character() + .filter(|_| !stroke.shortcut()) + .map_or(PendingTyped::Other, PendingTyped::Character) + }); + return Some(Output::Stroke(stroke)); } } self.restart(); None } - pub fn succeeded(&mut self) { - if self + pub fn succeeded(&mut self) -> bool { + self.succeeded_with_context(None) + } + pub fn succeeded_with_context(&mut self, context: Option) -> bool { + let completed = self .activation .take() - .is_some_and(|revision| self.scan.complete_activation(revision)) - { - for modifier in &mut self.modifiers { - if *modifier == Modifier::Once { + .is_some_and(|revision| self.scan.complete_activation(revision)); + if completed { + let typed = self.pending_typed.take(); + let retain_auto_shift = self.auto_shift + && (matches!( + typed, + Some(PendingTyped::Character(character)) if !character.is_alphabetic() + ) || matches!( + typed, + Some(PendingTyped::Punctuation(_) | PendingTyped::Prediction) + )); + for (index, modifier) in self.modifiers.iter_mut().enumerate() { + if *modifier == Modifier::Once && !(index == 0 && retain_auto_shift) { *modifier = Modifier::Off; } } + match typed { + Some(PendingTyped::Character(character)) => { + self.owned_space = if character == ' ' { context } else { None }; + if character.is_alphabetic() { + self.clear_auto_capital(); + } + } + Some(PendingTyped::Punctuation(mark)) => { + self.owned_space = context; + if matches!(mark, '.' | '!' | '?') { + self.capitalize_next = true; + self.capital_context = context; + self.reconcile_auto_shift(); + } + } + Some(PendingTyped::Navigation) => { + self.owned_space = None; + self.clear_auto_capital(); + } + Some(PendingTyped::Other) => { + self.owned_space = None; + self.clear_auto_capital(); + } + Some(PendingTyped::Prediction) | None => {} + } self.activation = None; self.restart(); if self.wait_after_typing && self.scan.options.automatic { @@ -392,11 +548,36 @@ impl Keyboard { self.scan.suspended = true; } } + completed + } + pub fn prediction_inserted( + &mut self, + trailing_space: bool, + contains_letter: bool, + context: Option, + ) { + self.owned_space = if trailing_space { context } else { None }; + if contains_letter { + self.clear_auto_capital(); + } + } + fn discard_stale_context(&mut self, context: Option) { + if self.owned_space.is_some() && self.owned_space != context { + self.owned_space = None; + } + if self.capital_context.is_some() && self.capital_context != context { + self.clear_auto_capital(); + } } pub fn reset_context(&mut self) { self.modifiers = [Modifier::Off; 4]; self.caps = false; self.activation = None; + self.owned_space = None; + self.capitalize_next = false; + self.auto_shift = false; + self.capital_context = None; + self.pending_typed = None; self.predictions(None, false); self.restart(); } @@ -404,6 +585,11 @@ impl Keyboard { self.modifiers = [Modifier::Off; 4]; self.caps = false; self.activation = None; + self.owned_space = None; + self.capitalize_next = false; + self.auto_shift = false; + self.capital_context = None; + self.pending_typed = None; self.restart(); self.error = true; self.scan.suspended = true; @@ -417,14 +603,7 @@ impl Keyboard { .cloned() .unwrap_or_default(), Key::Character(' ', _) => "Space".into(), - Key::Character(..) => Stroke { - key, - modifiers: self.modifiers.map(|m| m != Modifier::Off), - caps: self.caps, - } - .character() - .unwrap() - .to_string(), + Key::Character(..) => self.stroke(key).character().unwrap().to_string(), Key::Named(name) => match name { "ArrowUp" => "↑", "ArrowDown" => "↓", @@ -563,6 +742,204 @@ impl Keyboard { #[cfg(test)] mod tests { use super::*; + + fn context(epoch: u64) -> Option { + Some(TypingContext { + foreground: 7, + activity: epoch, + }) + } + + #[test] + fn smart_punctuation_owns_spacing_and_capitalizes_one_letter() { + let mut keyboard = Keyboard::new(false); + let space = Key::Character(' ', ' '); + assert!(matches!( + keyboard.choose_with_context(space, context(1)), + Some(Output::Stroke(_)) + )); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.choose_with_context(space, context(1)), None); + assert_eq!( + keyboard.choose_with_context(Key::Character('.', '>'), context(1)), + Some(Output::Punctuation(Punctuation { + mark: '.', + owned_space: context(1), + })) + ); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.label(Key::Character('a', 'A')), "A"); + assert_eq!(keyboard.choose_with_context(space, context(1)), None); + assert_eq!( + keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), + Some(Output::Stroke(Stroke { + key: Key::Character('a', 'A'), + modifiers: [true, false, false, false], + caps: false, + })) + ); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.label(Key::Character('b', 'B')), "b"); + } + + #[test] + fn prose_marks_use_smart_spacing_but_numeric_period_is_literal() { + let mut keyboard = Keyboard::new(false); + for (key, mark) in [ + (Key::Character(',', '<'), ','), + (Key::Character(';', ':'), ';'), + (Key::Character('.', '>'), '.'), + ] { + assert!( + matches!(keyboard.choose_with_context(key, context(1)), Some(Output::Punctuation(p)) if p.mark == mark) + ); + keyboard.succeeded_with_context(context(1)); + } + for (key, mark) in [ + (Key::Character('/', '?'), '?'), + (Key::Character(';', ':'), ':'), + ] { + keyboard.reset_context(); + keyboard.choose_with_context(Key::Modifier(0), context(1)); + assert!( + matches!(keyboard.choose_with_context(key, context(1)), Some(Output::Punctuation(p)) if p.mark == mark) + ); + keyboard.succeeded_with_context(context(1)); + } + keyboard.reset_context(); + keyboard.choose_with_context(Key::Page(Page::Numbers), context(1)); + assert!(matches!( + keyboard.choose_with_context(Key::Character('.', '.'), context(1)), + Some(Output::Stroke(_)) + )); + keyboard.succeeded_with_context(context(1)); + assert!( + matches!(keyboard.choose_with_context(Key::Character('!', '!'), context(1)), Some(Output::Punctuation(p)) if p.mark == '!') + ); + } + + #[test] + fn external_activity_and_failed_input_discard_owned_space() { + let mut keyboard = Keyboard::new(false); + keyboard.choose_with_context(Key::Character(' ', ' '), context(1)); + keyboard.succeeded_with_context(context(1)); + assert!( + matches!(keyboard.choose_with_context(Key::Character(',', '<'), context(2)), Some(Output::Punctuation(p)) if p.owned_space.is_none()) + ); + keyboard.failed(); + assert!(!keyboard.capitalize_next); + assert_eq!(keyboard.owned_space, None); + keyboard.handle(Action::Select); + keyboard.choose_with_context(Key::Character('.', '>'), context(2)); + keyboard.succeeded_with_context(context(2)); + assert_eq!(keyboard.label(Key::Character('a', 'A')), "A"); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + assert!( + matches!(keyboard.choose_with_context(Key::Character('a', 'A'), context(3)), Some(Output::Stroke(stroke)) if stroke.character() == Some('a')) + ); + assert_eq!(keyboard.modifiers[0], Modifier::Off); + keyboard.succeeded_with_context(context(3)); + keyboard.choose_with_context(Key::Character('.', '>'), context(3)); + keyboard.succeeded_with_context(context(3)); + keyboard.prediction_inserted(true, true, context(3)); + assert!(!keyboard.capitalize_next); + assert_eq!(keyboard.owned_space, context(3)); + keyboard.reset_context(); + assert_eq!(keyboard.owned_space, None); + } + + #[test] + fn automatic_capital_does_not_toggle_manual_caps_or_change_shortcuts() { + let mut keyboard = Keyboard::new(false); + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + keyboard.choose_with_context(Key::Modifier(1), context(1)); + assert!( + matches!(keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), Some(Output::Stroke(stroke)) if stroke.shortcut() && !stroke.modifiers[0]) + ); + keyboard.succeeded_with_context(context(1)); + assert!(!keyboard.capitalize_next); + assert_eq!(keyboard.modifiers[0], Modifier::Off); + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + assert!(keyboard.capitalize_next); + keyboard.choose_with_context(Key::Caps, context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Off); + assert!( + matches!(keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), Some(Output::Stroke(stroke)) if stroke.character() == Some('A') && !stroke.modifiers[0]) + ); + keyboard.succeeded_with_context(context(1)); + assert!(!keyboard.capitalize_next); + assert!(keyboard.caps); + assert_eq!(keyboard.label(Key::Character('b', 'B')), "B"); + } + + #[test] + fn sentence_mark_sets_visible_one_use_shift_until_a_letter() { + let mut keyboard = Keyboard::new(false); + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + assert!(!keyboard.prediction_shift()); + assert_eq!(keyboard.label(Key::Modifier(0)), "Shift\nNext key"); + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let frame = keyboard.frame(screen, 1.0, ScannerColor::default()); + assert!( + frame + .tiles + .iter() + .find(|tile| tile.text.starts_with("Shift")) + .unwrap() + .style + .unwrap() + .active + ); + assert!(matches!( + keyboard.choose_with_context(Key::Character(',', '<'), context(1)), + Some(Output::Punctuation(punctuation)) if punctuation.mark == ',' + )); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + assert!(matches!( + keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), + Some(Output::Stroke(stroke)) if stroke.modifiers[0] && stroke.character() == Some('A') + )); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Off); + assert!(!keyboard.capitalize_next); + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + keyboard.choose_with_context(Key::Modifier(0), context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Off); + assert!(!keyboard.capitalize_next); + keyboard.choose_with_context(Key::Modifier(0), context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + assert!(keyboard.prediction_shift()); + } + + #[test] + fn deleting_a_sentence_boundary_clears_pending_capital() { + let mut keyboard = Keyboard::new(false); + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + assert!(keyboard.capitalize_next); + keyboard.choose_with_context(Key::Named("Backspace"), context(1)); + keyboard.succeeded_with_context(context(1)); + assert!(!keyboard.capitalize_next); + assert_eq!(keyboard.modifiers[0], Modifier::Off); + keyboard.choose_with_context(Key::Named("Backspace"), context(1)); + keyboard.succeeded_with_context(context(1)); + assert!(matches!( + keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), + Some(Output::Stroke(stroke)) if stroke.character() == Some('a') + )); + } #[test] fn typing_wait_consumes_resume_and_restarts_a_full_interval() { use crate::scan_preferences::{Direction, Pattern, Resolved}; diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index addaf328..a87fce0d 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -277,6 +277,7 @@ it("customises individual settings, restores defaults and returns focus without expect(screen.getByRole("heading", { name: "Keyboard" })).toHaveFocus(); expect(screen.queryByRole("checkbox", { name: "Auto select" })).not.toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "Word prediction" })).toBeEnabled(); + expect(screen.getByText(/The scanning keyboard spaces punctuation for you/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Reverse" })); await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: expect.objectContaining({ scanPreferences: expect.objectContaining({ keyboard: { direction: "reverse" }, menu: {} }) }) diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index adf856db..d633cca0 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -129,6 +129,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl } + {area === 'keyboard' &&

The scanning keyboard spaces punctuation for you. Period, question mark, and exclamation mark capitalize the next letter. The Numbers page period stays a decimal point.

} {area === 'keyboard' && update('wordPrediction', value)} /> }