From acb9380f749c4ab3d71be16522fb85f4e8d9418f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 14:43:05 +0100 Subject: [PATCH 1/7] Handle spacing and capitals for scanned punctuation --- src-tauri/src/point_scan_runtime.rs | 20 ++ src-tauri/src/point_workflow.rs | 12 +- src-tauri/src/prediction/mod.rs | 74 +++++++- src-tauri/src/scan_executor.rs | 67 ++++++- src-tauri/src/scan_keyboard.rs | 284 ++++++++++++++++++++++++++-- src/PointScan.test.tsx | 1 + src/settings/ScannerPreferences.tsx | 1 + 7 files changed, 435 insertions(+), 24 deletions(-) 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/mod.rs b/src-tauri/src/prediction/mod.rs index e57ab203..c17c815e 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -12,6 +12,7 @@ use std::{ path::Path, process::{Child, ChildStdin, Command, Stdio}, sync::mpsc, + sync::OnceLock, thread::JoinHandle, time::{Duration, Instant}, }; @@ -259,6 +260,31 @@ impl Service { } } thread_local! { static SERVICE: RefCell = RefCell::new(Service::default()); } +static KEYBOARD_ACTIVITY: OnceLock<()> = OnceLock::new(); + +pub fn start_keyboard_activity(ignored: &[String]) { + KEYBOARD_ACTIVITY.get_or_init(|| { + let ignored = ignored + .iter() + .filter_map(|name| crate::switch_input::prediction_key_code(name)) + .collect(); + std::thread::spawn(move || { + let _ = activity::start(ignored); + }); + }); +} + +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 +310,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(); @@ -395,8 +437,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 +448,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; @@ -459,6 +509,24 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign mod tests { use super::*; #[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/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..6a3122d0 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -50,6 +50,23 @@ 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), + Other, + Prediction, +} impl Stroke { pub fn character(self) -> Option { let Key::Character(base, shifted) = self.key else { @@ -71,6 +88,7 @@ impl Stroke { pub enum Output { Prediction { token: u64, index: usize }, Stroke(Stroke), + Punctuation(Punctuation), Close, } @@ -185,6 +203,10 @@ pub struct Keyboard { prefer_predictions: bool, wait_after_typing: bool, waiting_after_typing: bool, + owned_space: Option, + capitalize_next: bool, + capital_context: Option, + pending_typed: Option, } impl Keyboard { #[cfg(test)] @@ -211,6 +233,10 @@ impl Keyboard { prefer_predictions: true, wait_after_typing: false, waiting_after_typing: false, + owned_space: None, + capitalize_next: false, + capital_context: None, + pending_typed: None, } } pub fn with_wait_after_typing(mut self, enabled: bool) -> Self { @@ -313,7 +339,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 +366,40 @@ 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 modifiers = self.modifiers.map(|m| m != Modifier::Off); + let mut caps = self.caps; + if matches!(key, Key::Character(base, _) if base.is_ascii_alphabetic()) + && self.capitalize_next + && !modifiers[1..].iter().any(|modifier| *modifier) + { + caps = !modifiers[0]; + } + Stroke { + key, + modifiers, + caps, + } + } + 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,6 +408,7 @@ impl Keyboard { } self.activation = self.scan.begin_activation(); self.activation?; + self.pending_typed = Some(PendingTyped::Prediction); return Some(Output::Prediction { token: batch.token, index, @@ -359,26 +423,64 @@ 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( + 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)) - { + .is_some_and(|revision| self.scan.complete_activation(revision)); + if completed { + match self.pending_typed.take() { + Some(PendingTyped::Character(character)) => { + self.owned_space = if character == ' ' { context } else { None }; + if character.is_alphabetic() { + self.capitalize_next = false; + self.capital_context = None; + } + } + Some(PendingTyped::Punctuation(mark)) => { + self.owned_space = context; + if matches!(mark, '.' | '!' | '?') { + self.capitalize_next = true; + self.capital_context = context; + } + } + Some(PendingTyped::Other) => self.owned_space = None, + Some(PendingTyped::Prediction) | None => {} + } for modifier in &mut self.modifiers { if *modifier == Modifier::Once { *modifier = Modifier::Off; @@ -392,11 +494,37 @@ 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.capitalize_next = false; + self.capital_context = None; + } + } + 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.capitalize_next = false; + self.capital_context = None; + } } 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.capital_context = None; + self.pending_typed = None; self.predictions(None, false); self.restart(); } @@ -404,6 +532,10 @@ impl Keyboard { self.modifiers = [Modifier::Off; 4]; self.caps = false; self.activation = None; + self.owned_space = None; + self.capitalize_next = false; + self.capital_context = None; + self.pending_typed = None; self.restart(); self.error = true; self.scan.suspended = true; @@ -417,14 +549,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 +688,129 @@ 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: [false; 4], + caps: true, + })) + ); + 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)); + } + keyboard.choose_with_context(Key::Modifier(0), context(1)); + for (key, mark) in [ + (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)); + keyboard.choose_with_context(Key::Modifier(0), context(1)); + } + 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!( + matches!(keyboard.choose_with_context(Key::Character('a', 'A'), context(3)), Some(Output::Stroke(stroke)) if stroke.character() == Some('a')) + ); + 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_consume_shortcuts() { + let mut keyboard = Keyboard::new(false); + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + 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()) + ); + keyboard.succeeded_with_context(context(1)); + assert!(keyboard.capitalize_next); + keyboard.choose_with_context(Key::Caps, context(1)); + assert!( + matches!(keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), Some(Output::Stroke(stroke)) if stroke.character() == Some('A')) + ); + keyboard.succeeded_with_context(context(1)); + assert!(!keyboard.capitalize_next); + assert!(keyboard.caps); + assert_eq!(keyboard.label(Key::Character('b', 'B')), "B"); + } #[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)} /> } From 715749490b4c2138431c310ee2c4160cde5b07c5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 14:49:31 +0100 Subject: [PATCH 2/7] Keep keyboard context safe across reassignment and edits --- src-tauri/src/prediction/activity.rs | 54 ++++++++++++++++++++++------ src-tauri/src/prediction/mod.rs | 10 +++--- src-tauri/src/prediction/worker.rs | 3 +- src-tauri/src/scan_keyboard.rs | 31 ++++++++++++++-- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/prediction/activity.rs b/src-tauri/src/prediction/activity.rs index d8b5adcc..f2bd959e 100644 --- a/src-tauri/src/prediction/activity.rs +++ b/src-tauri/src/prediction/activity.rs @@ -1,7 +1,29 @@ //! 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() +} pub fn snapshot() -> (u64, bool) { (EPOCH.load(Ordering::SeqCst), HEALTHY.load(Ordering::SeqCst)) } @@ -24,16 +46,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 +73,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,7 +109,7 @@ 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, @@ -121,7 +138,7 @@ pub fn start(ignored: Vec) -> bool { } 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(); } @@ -141,6 +158,23 @@ pub fn start(ignored: Vec) -> bool { .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()); + } +} diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index c17c815e..2347aa63 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -263,13 +263,15 @@ thread_local! { static SERVICE: RefCell = RefCell::new(Service::default static KEYBOARD_ACTIVITY: OnceLock<()> = OnceLock::new(); pub fn start_keyboard_activity(ignored: &[String]) { - KEYBOARD_ACTIVITY.get_or_init(|| { - let ignored = ignored + activity::set_ignored( + ignored .iter() .filter_map(|name| crate::switch_input::prediction_key_code(name)) - .collect(); + .collect(), + ); + KEYBOARD_ACTIVITY.get_or_init(|| { std::thread::spawn(move || { - let _ = activity::start(ignored); + let _ = activity::start(); }); }); } diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 00e43b85..04bfb3f1 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)?; diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 6a3122d0..cd405321 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -64,6 +64,7 @@ pub struct Punctuation { enum PendingTyped { Character(char), Punctuation(char), + Navigation, Other, Prediction, } @@ -442,12 +443,14 @@ impl Keyboard { owned_space: self.owned_space.filter(|saved| Some(*saved) == context), })); } - self.pending_typed = Some( + self.pending_typed = Some(if matches!(stroke.key, Key::Named(_)) { + PendingTyped::Navigation + } else { stroke .character() .filter(|_| !stroke.shortcut()) - .map_or(PendingTyped::Other, PendingTyped::Character), - ); + .map_or(PendingTyped::Other, PendingTyped::Character) + }); return Some(Output::Stroke(stroke)); } } @@ -478,6 +481,11 @@ impl Keyboard { self.capital_context = context; } } + Some(PendingTyped::Navigation) => { + self.owned_space = None; + self.capitalize_next = false; + self.capital_context = None; + } Some(PendingTyped::Other) => self.owned_space = None, Some(PendingTyped::Prediction) | None => {} } @@ -811,6 +819,23 @@ mod tests { assert!(keyboard.caps); assert_eq!(keyboard.label(Key::Character('b', 'B')), "B"); } + + #[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); + 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}; From 113a36b48bef489b7eef0eecb184178f6a6fa017 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 14:53:48 +0100 Subject: [PATCH 3/7] Clear pending sentence capitalization after shortcuts --- src-tauri/src/scan_keyboard.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index cd405321..e29119ea 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -486,7 +486,11 @@ impl Keyboard { self.capitalize_next = false; self.capital_context = None; } - Some(PendingTyped::Other) => self.owned_space = None, + Some(PendingTyped::Other) => { + self.owned_space = None; + self.capitalize_next = false; + self.capital_context = None; + } Some(PendingTyped::Prediction) | None => {} } for modifier in &mut self.modifiers { @@ -800,7 +804,7 @@ mod tests { } #[test] - fn automatic_capital_does_not_toggle_manual_caps_or_consume_shortcuts() { + 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)); @@ -809,6 +813,9 @@ mod tests { matches!(keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), Some(Output::Stroke(stroke)) if stroke.shortcut()) ); keyboard.succeeded_with_context(context(1)); + assert!(!keyboard.capitalize_next); + 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!( From 1afe889c900254f664375abe0c3c7763118a8938 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 14:56:09 +0100 Subject: [PATCH 4/7] Retry keyboard activity observation after hook failure --- src-tauri/src/prediction/mod.rs | 37 ++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 2347aa63..5c6a1a5f 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -11,8 +11,8 @@ use std::{ cell::RefCell, path::Path, process::{Child, ChildStdin, Command, Stdio}, + sync::atomic::{AtomicU8, Ordering}, sync::mpsc, - sync::OnceLock, thread::JoinHandle, time::{Duration, Instant}, }; @@ -260,7 +260,18 @@ impl Service { } } thread_local! { static SERVICE: RefCell = RefCell::new(Service::default()); } -static KEYBOARD_ACTIVITY: OnceLock<()> = OnceLock::new(); +// 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( @@ -269,11 +280,16 @@ pub fn start_keyboard_activity(ignored: &[String]) { .filter_map(|name| crate::switch_input::prediction_key_code(name)) .collect(), ); - KEYBOARD_ACTIVITY.get_or_init(|| { + if claim_keyboard_activity_start(&KEYBOARD_ACTIVITY, activity::snapshot().1) { std::thread::spawn(move || { - let _ = activity::start(); + 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 { @@ -511,6 +527,17 @@ 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()); From ce32bdfc084f09cd8e930ed91ea08dd1501c6547 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 14:59:17 +0100 Subject: [PATCH 5/7] Stop disabled macOS activity tap before retry --- src-tauri/src/prediction/activity.rs | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/prediction/activity.rs b/src-tauri/src/prediction/activity.rs index f2bd959e..a7871bcf 100644 --- a/src-tauri/src/prediction/activity.rs +++ b/src-tauri/src/prediction/activity.rs @@ -24,6 +24,14 @@ fn ignored_key(code: u32) -> bool { .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)) } @@ -114,6 +122,8 @@ pub fn start() -> bool { 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(); @@ -133,7 +143,11 @@ pub fn start() -> 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)) @@ -152,7 +166,9 @@ pub fn start() -> bool { }, ); let _ = tx.send(false); - unavailable(); + if needs_exit_invalidation(&disabled) { + unavailable(); + } }); rx.recv_timeout(std::time::Duration::from_millis(500)) .unwrap_or(false) @@ -177,4 +193,13 @@ mod tests { 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)); + } } From 15275a1034ce6793845a445389e375409ebeff01 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 15:38:08 +0100 Subject: [PATCH 6/7] Show sentence capitalization as one-use Shift --- src-tauri/src/scan_keyboard.rs | 144 ++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index e29119ea..b91259d4 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -206,6 +206,7 @@ pub struct Keyboard { waiting_after_typing: bool, owned_space: Option, capitalize_next: bool, + auto_shift: bool, capital_context: Option, pending_typed: Option, } @@ -236,6 +237,7 @@ impl Keyboard { waiting_after_typing: false, owned_space: None, capitalize_next: false, + auto_shift: false, capital_context: None, pending_typed: None, } @@ -377,18 +379,35 @@ impl Keyboard { self.choose_with_context(key, None) } fn stroke(&self, key: Key) -> Stroke { - let modifiers = self.modifiers.map(|m| m != Modifier::Off); - let mut caps = self.caps; - if matches!(key, Key::Character(base, _) if base.is_ascii_alphabetic()) - && self.capitalize_next - && !modifiers[1..].iter().any(|modifier| *modifier) + 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)) { - caps = !modifiers[0]; + modifiers[0] = false; } Stroke { key, modifiers, - caps, + caps: self.caps, + } + } + 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 { @@ -415,8 +434,18 @@ impl Keyboard { 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 { + self.auto_shift = false; + self.capitalize_next = false; + self.capital_context = None; + } + 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; @@ -466,12 +495,25 @@ impl Keyboard { .take() .is_some_and(|revision| self.scan.complete_activation(revision)); if completed { - match self.pending_typed.take() { + 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.capitalize_next = false; - self.capital_context = None; + self.clear_auto_capital(); } } Some(PendingTyped::Punctuation(mark)) => { @@ -479,25 +521,19 @@ impl Keyboard { if matches!(mark, '.' | '!' | '?') { self.capitalize_next = true; self.capital_context = context; + self.reconcile_auto_shift(); } } Some(PendingTyped::Navigation) => { self.owned_space = None; - self.capitalize_next = false; - self.capital_context = None; + self.clear_auto_capital(); } Some(PendingTyped::Other) => { self.owned_space = None; - self.capitalize_next = false; - self.capital_context = None; + self.clear_auto_capital(); } Some(PendingTyped::Prediction) | None => {} } - for modifier in &mut self.modifiers { - if *modifier == Modifier::Once { - *modifier = Modifier::Off; - } - } self.activation = None; self.restart(); if self.wait_after_typing && self.scan.options.automatic { @@ -516,8 +552,7 @@ impl Keyboard { ) { self.owned_space = if trailing_space { context } else { None }; if contains_letter { - self.capitalize_next = false; - self.capital_context = None; + self.clear_auto_capital(); } } fn discard_stale_context(&mut self, context: Option) { @@ -525,8 +560,7 @@ impl Keyboard { self.owned_space = None; } if self.capital_context.is_some() && self.capital_context != context { - self.capitalize_next = false; - self.capital_context = None; + self.clear_auto_capital(); } } pub fn reset_context(&mut self) { @@ -535,6 +569,7 @@ impl Keyboard { 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); @@ -546,6 +581,7 @@ impl Keyboard { 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(); @@ -732,8 +768,8 @@ mod tests { keyboard.choose_with_context(Key::Character('a', 'A'), context(1)), Some(Output::Stroke(Stroke { key: Key::Character('a', 'A'), - modifiers: [false; 4], - caps: true, + modifiers: [true, false, false, false], + caps: false, })) ); keyboard.succeeded_with_context(context(1)); @@ -753,17 +789,18 @@ mod tests { ); keyboard.succeeded_with_context(context(1)); } - keyboard.choose_with_context(Key::Modifier(0), 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.choose_with_context(Key::Modifier(0), 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)), @@ -790,9 +827,11 @@ mod tests { 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)); @@ -808,18 +847,21 @@ mod tests { 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()) + 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')) + 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); @@ -827,6 +869,45 @@ mod tests { 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_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); + } + #[test] fn deleting_a_sentence_boundary_clears_pending_capital() { let mut keyboard = Keyboard::new(false); @@ -836,6 +917,7 @@ mod tests { 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!( From a3aefcd6a9cd2c0d44003269cf7eb894a8aa3308 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 24 Sep 2026 15:43:57 +0100 Subject: [PATCH 7/7] Preserve prediction casing and allow cancelling automatic Shift --- src-tauri/src/prediction/mod.rs | 4 ++-- src-tauri/src/prediction/worker.rs | 8 ++++++++ src-tauri/src/scan_keyboard.rs | 23 +++++++++++++++++++---- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 5c6a1a5f..91569cc5 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -391,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), ); @@ -510,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, } }; diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 04bfb3f1..d6dba19d 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -458,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_keyboard.rs b/src-tauri/src/scan_keyboard.rs index b91259d4..341963fe 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -392,6 +392,9 @@ impl Keyboard { 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; @@ -436,11 +439,14 @@ impl Keyboard { } Key::Modifier(i) => { if i == 0 && self.capitalize_next { - self.auto_shift = false; - self.capitalize_next = false; - self.capital_context = None; + 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(); } - self.modifiers[i] = self.modifiers[i].next(); } Key::Caps => { self.caps = !self.caps; @@ -875,6 +881,7 @@ mod tests { 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, @@ -906,6 +913,14 @@ mod tests { 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]