From ecbc4cb9f53c1a00a4d01cc4356d7a481efced48 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 17:28:41 +0100 Subject: [PATCH] Add optional wait after keyboard typing Preserve automatic scanning by default and allow Select-only resumption after successful typing. Persist the keyboard-specific preference and cover timing, predictions, pause, cleanup and configuration compatibility. --- src-tauri/src/point_scan.rs | 20 ++++ src-tauri/src/point_workflow.rs | 67 +++++++++++- src-tauri/src/scan_keyboard.rs | 152 ++++++++++++++++++++++++++++ src/PointScan.test.tsx | 17 ++++ src/scanning/useScanning.ts | 2 + src/settings/ScannerPreferences.tsx | 4 + 6 files changed, 258 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index f9d8f2e9..c1599365 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -18,6 +18,7 @@ pub struct Config { #[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")] pub scan_preferences: crate::scan_preferences::Preferences, pub word_prediction: bool, + pub keyboard_wait_after_typing: bool, pub scanner_color: crate::scanning::ScannerColor, pub mode: Mode, pub automatic: bool, @@ -36,6 +37,7 @@ impl Default for Config { Self { scan_preferences: Default::default(), word_prediction: true, + keyboard_wait_after_typing: false, scanner_color: Default::default(), mode: Mode::Line, automatic: true, @@ -84,6 +86,7 @@ impl Config { menu_scan: self.resolved(crate::scan_preferences::Area::Menu), keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard), word_prediction: self.word_prediction, + keyboard_wait_after_typing: self.keyboard_wait_after_typing, scanner_color: self.resolved(crate::scan_preferences::Area::Point).color, mode: self.mode, speed: self.speed, @@ -106,6 +109,7 @@ pub struct PointSettings { pub menu_scan: crate::scan_preferences::Resolved, pub keyboard_scan: crate::scan_preferences::Resolved, pub word_prediction: bool, + pub keyboard_wait_after_typing: bool, pub scanner_color: crate::scanning::ScannerColor, pub mode: Mode, pub speed: usize, @@ -545,6 +549,21 @@ mod tests { assert!(!restored.word_prediction); } #[test] + fn keyboard_wait_defaults_off_and_round_trips() { + let legacy: Config = serde_json::from_str("{}").unwrap(); + assert!(!legacy.keyboard_wait_after_typing); + for enabled in [false, true] { + let config = Config { + keyboard_wait_after_typing: enabled, + ..Default::default() + }; + let restored: Config = + serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap(); + assert_eq!(restored.keyboard_wait_after_typing, enabled); + assert_eq!(restored.point().keyboard_wait_after_typing, enabled); + } + } + #[test] fn auto_select_delays_are_bounded_and_round_trip() { for delay in [100, 500, 1000, 100_000] { let config = Config { @@ -942,6 +961,7 @@ mod tests { json["autoSelectDelayMs"] = serde_json::json!(1000); json["scannerColor"] = serde_json::json!("blue"); json["wordPrediction"] = serde_json::json!(true); + json["keyboardWaitAfterTyping"] = serde_json::json!(false); json["scanPreferences"] = serde_json::to_value(crate::scan_preferences::Preferences::default()).unwrap(); assert_eq!(serde_json::to_value(&config).unwrap(), json); diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index b39d4c23..cec28d80 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -102,7 +102,8 @@ impl Workflow { keyboard: crate::scan_keyboard::Keyboard::configured( cfg!(target_os = "macos"), keyboard_options, - ), + ) + .with_wait_after_typing(config.keyboard_wait_after_typing), keyboard_area: screen, point: Engine::new(config, screen, scale)?, stage: Stage::Idle, @@ -138,7 +139,8 @@ impl Workflow { self.keyboard = crate::scan_keyboard::Keyboard::configured( cfg!(target_os = "macos"), self.point.config.keyboard_scan, - ); + ) + .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); self.parent_menu.clear(); self.stage = Stage::Point; self.point.start(); @@ -179,7 +181,8 @@ impl Workflow { self.keyboard = crate::scan_keyboard::Keyboard::configured( cfg!(target_os = "macos"), self.point.config.keyboard_scan, - ); + ) + .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); return Some(Request::OpenKeyboard { point: (item == Item::TypeHere).then_some(self.source), }); @@ -297,7 +300,8 @@ impl Technique for Workflow { self.keyboard = crate::scan_keyboard::Keyboard::configured( cfg!(target_os = "macos"), self.point.config.keyboard_scan, - ); + ) + .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); self.stage = Stage::Idle; self.parent_menu.clear(); self.pending = None; @@ -604,6 +608,61 @@ mod tests { assert!(w.frame().tiles.is_empty()); } #[test] + fn typing_wait_survives_pause_hold_and_predictions_but_not_stop_or_config_reset() { + let mut s = session(true); + let config = Config { + keyboard_wait_after_typing: true, + block_interval_ms: 250, + ..Default::default() + }; + s.technique.apply_config(config.point(), false); + s.action(Action::Select); + s.technique.selected(Item::Keyboard); + s.technique.execution_succeeded(); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::Keyboard) + ); + assert!(s.action(Action::Select).is_none()); + assert!(matches!( + s.action(Action::Select), + Some(Request::Keyboard(_)) + )); + s.technique.execution_succeeded(); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::KeyboardSuspended) + ); + let waiting = s.frame(); + s.action(Action::Pause); + s.tick(250, false); + s.action(Action::Pause); + s.tick(250, true); + s.tick(250, false); + assert_eq!(s.frame(), waiting); + assert!(s.action(Action::Select).is_none()); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::Keyboard) + ); + let resumed = s.frame(); + s.tick(249, false); + assert_eq!(s.frame(), resumed); + s.tick(1, false); + assert_ne!(s.frame(), resumed); + s.action(Action::Stop); + assert!(!s.active()); + s.technique.execution_succeeded(); + assert_eq!(s.technique.phase(), Phase::default()); + s.technique.apply_config(config.point(), true); + s.technique.selected(Item::Keyboard); + s.technique.execution_succeeded(); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::Keyboard) + ); + } + #[test] fn keyboard_key_acknowledgement_is_exactly_once_and_reset_discards_state() { let mut s = session(false); let w = &mut s.technique; diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index b07aa40d..62b29d2f 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -184,6 +184,8 @@ pub struct Keyboard { predictions: Option, queued_predictions: Option, prefer_predictions: bool, + wait_after_typing: bool, + waiting_after_typing: bool, } impl Keyboard { #[cfg(test)] @@ -208,8 +210,14 @@ impl Keyboard { predictions: None, queued_predictions: None, prefer_predictions: true, + wait_after_typing: false, + waiting_after_typing: false, } } + pub fn with_wait_after_typing(mut self, enabled: bool) -> Self { + self.wait_after_typing = enabled; + self + } pub fn enable_predictions(&mut self, enabled: bool) { if self.prediction_enabled == enabled { return; @@ -239,6 +247,11 @@ impl Keyboard { self.queued_predictions = None; return; } + if self.waiting_after_typing { + self.predictions = batch; + self.queued_predictions = None; + return; + } if self.prediction_row_active() { if self.predictions.as_ref().map(|b| b.token) != batch.as_ref().map(|b| b.token) { self.predictions = None; @@ -290,6 +303,7 @@ impl Keyboard { self.scan.suspended } fn restart(&mut self) { + self.waiting_after_typing = false; self.prefer_predictions = true; self.scan.restart(); self.skip_disabled(false); @@ -373,6 +387,11 @@ impl Keyboard { } self.activation = None; self.restart(); + if self.wait_after_typing && self.scan.options.automatic { + self.waiting_after_typing = true; + self.prefer_predictions = false; + self.scan.suspended = true; + } } } pub fn failed(&mut self) { @@ -554,6 +573,8 @@ impl Keyboard { }; let text = if self.error { "Input failed · Select to try again".to_owned() + } else if self.waiting_after_typing { + "Press Select to continue typing.".to_owned() } else if self.scan.suspended { "Keyboard paused · Select to resume".to_owned() } else if self.scan.nav.escaping() { @@ -599,6 +620,137 @@ impl Keyboard { mod tests { use super::*; #[test] + fn typing_wait_consumes_resume_and_restarts_a_full_interval() { + use crate::scan_preferences::{Direction, Pattern, Resolved}; + for direction in [Direction::Forward, Direction::Reverse] { + for pattern in [Pattern::Grouped, Pattern::Linear] { + for automatic in [false, true] { + for enabled in [false, true] { + let mut k = Keyboard::configured( + false, + Resolved { + direction, + pattern, + automatic, + pass_limit: 1, + ..Default::default() + }, + ) + .with_wait_after_typing(enabled); + assert!(matches!( + k.choose(Key::Character('a', 'A')), + Some(Output::Stroke(_)) + )); + assert!(k.handle(Action::Select).is_none()); + k.succeeded(); + assert_eq!(k.waiting_after_typing, automatic && enabled); + assert_eq!(k.suspended(), automatic && enabled); + if automatic && enabled { + let position = k.scan.position(&k.rows); + k.advance(5000, 1000); + assert_eq!(k.scan.position(&k.rows), position); + k.handle(Action::Next); + k.handle(Action::Back); + k.handle(Action::Reverse); + assert!(k.waiting_after_typing); + assert!(k.handle(Action::Select).is_none()); + assert!(!k.waiting_after_typing); + k.advance(999, 1000); + assert_eq!(k.scan.position(&k.rows), position); + k.advance(1, 1000); + assert_ne!(k.scan.position(&k.rows), position); + } + } + } + } + } + } + #[test] + fn predictions_and_duplicate_completion_cannot_resume_typing_wait() { + let mut k = Keyboard::new(false).with_wait_after_typing(true); + k.enable_predictions(true); + k.predictions( + Some(crate::prediction::worker::Batch { + token: 1, + words: vec!["hello".into()], + }), + false, + ); + assert!(matches!( + k.choose(Key::Prediction(0)), + Some(Output::Prediction { token: 1, index: 0 }) + )); + k.succeeded(); + for token in 2..5 { + k.predictions( + Some(crate::prediction::worker::Batch { + token, + words: vec!["world".into()], + }), + false, + ); + k.succeeded(); + k.advance(5000, 1000); + assert!(k.waiting_after_typing); + let frame = k.frame( + Rect { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 800.0, + }, + 1.0, + ScannerColor::Blue, + ); + assert!(frame + .tiles + .iter() + .any(|t| t.text == "Press Select to continue typing.")); + assert!(!frame.tiles.iter().any(|t| t.selected)); + } + assert!(k.handle(Action::Select).is_none()); + assert_eq!(k.predictions.as_ref().unwrap().token, 4); + assert!(k.handle(Action::Select).is_none()); + assert_eq!( + k.handle(Action::Select), + Some(Output::Prediction { token: 4, index: 0 }) + ); + assert!(k.handle(Action::Select).is_none()); + } + #[test] + fn keyboard_controls_remain_immediate_and_failures_keep_error_recovery() { + for key in [ + Key::Modifier(0), + Key::Caps, + Key::Dock, + Key::Page(Page::Numbers), + ] { + let mut k = Keyboard::new(false).with_wait_after_typing(true); + assert!(k.choose(key).is_none()); + assert!(!k.waiting_after_typing); + assert!(!k.suspended()); + } + for key in [ + Key::Character(' ', ' '), + Key::Named("Backspace"), + Key::Named("ArrowLeft"), + ] { + let mut k = Keyboard::new(false).with_wait_after_typing(true); + k.choose(key); + k.succeeded(); + assert!(k.waiting_after_typing); + } + let mut k = Keyboard::new(false).with_wait_after_typing(true); + k.choose(Key::Named("Enter")); + k.failed(); + assert!(!k.waiting_after_typing); + assert!(k.error); + assert!(k.suspended()); + assert!(k.handle(Action::Select).is_none()); + assert!(!k.error); + assert!(!k.waiting_after_typing); + } + #[test] fn arriving_predictions_receive_a_full_scan_interval() { let mut keyboard = Keyboard::new(false); keyboard.enable_predictions(true); diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 45964bf3..69f22190 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -347,3 +347,20 @@ it("brings the area header into view with help expanded and restores the overvie else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); } }); + +it("saves the keyboard after-typing choice and preserves it while manual", async () => { + mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? initial : { ...initial, config: args.config }); + render(); + await screen.findByText(initial.message); + expect(screen.queryByText("After typing")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); + expect(screen.getByRole("button", { name: "Continue scanning" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Wait for Select" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: { ...defaultPointScanConfig, keyboardWaitAfterTyping: true } })); + fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Wait for Select" })).toBeDisabled()); + expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Wait for Select" })).toBeEnabled()); + expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true"); +}); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 365f4d8a..2644ef18 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -5,6 +5,7 @@ export type ScannerColor = "red" | "green" | "blue" | "yellow" | "white"; export type PointScanConfig = { scanPreferences?: import("./preferences").ScanPreferences; wordPrediction: boolean; + keyboardWaitAfterTyping: boolean; scannerColor: ScannerColor; mode: "line" | "grid"; automatic: boolean; @@ -29,6 +30,7 @@ export type PointScanState = { }; export const defaultPointScanConfig: PointScanConfig = { wordPrediction: true, + keyboardWaitAfterTyping: false, scannerColor: "blue", mode: "line", automatic: true, diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 5dd6c96a..5b41ba89 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -139,6 +139,10 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl } {field('automatic', 'Automatic scanning', locked => change('automatic', value)} />)} + {area === 'keyboard' && legend="After typing" disabled={disabled || !effective.automatic} value={config.keyboardWaitAfterTyping ? 'wait' : 'continue'} + options={[{ value: 'continue', label: 'Continue scanning' }, { value: 'wait', label: 'Wait for Select' }]} + onChange={value => update('keyboardWaitAfterTyping', value === 'wait')} + note={{ summary: effective.automatic ? 'After typing a key or suggestion, wait for Select before scanning again.' : 'Used in automatic keyboard scanning. Your choice is kept while scanning manually.' }} />} {field('intervalMs', 'Auto scan rate', locked => <>