From 0e0a8270e5c45f004da4292c7d1a9ab21efd12a5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:25:56 +0100 Subject: [PATCH 1/2] Apply after-selection preferences in the keyboard and mouse panel After a key, a suggestion or a mouse action, scanning starts from the beginning or from the item just selected, and moves by itself or waits for Select, as chosen. A stopped move or scroll follows the same choice. Changing page, leaving the panel and failures are unchanged. Closes #931 Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_scan.rs | 7 +-- src-tauri/src/point_workflow.rs | 41 +++++++++++++-- src-tauri/src/scan_items.rs | 93 ++++++++++++++++++++++++++++++++- src-tauri/src/scan_keyboard.rs | 77 +++++++++++++++++++++++++-- src-tauri/src/scan_mouse.rs | 65 ++++++++++++++++++++--- 5 files changed, 265 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 9d48bc25..ebd0a22c 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -123,7 +123,6 @@ impl Config { keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard), mouse_scan: self.resolved(crate::scan_preferences::Area::Mouse), word_prediction: self.word_prediction, - keyboard_wait_after_typing: self.keyboard_wait_after_typing, panel_avoids_pointer: self.panel_avoids_pointer, scanner_color: self.resolved(crate::scan_preferences::Area::Point).color, mode: self.mode, @@ -149,7 +148,6 @@ pub struct PointSettings { pub keyboard_scan: crate::scan_preferences::Resolved, pub mouse_scan: crate::scan_preferences::Resolved, pub word_prediction: bool, - pub keyboard_wait_after_typing: bool, pub panel_avoids_pointer: bool, pub scanner_color: crate::scanning::ScannerColor, pub mode: Mode, @@ -703,7 +701,10 @@ mod tests { 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); + assert_eq!( + restored.point().keyboard_scan.next_scan == crate::scan_preferences::NextScan::Wait, + enabled + ); } } #[test] diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index cc93a061..5daa6292 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -146,7 +146,9 @@ impl Workflow { cfg!(target_os = "macos"), keyboard_options, ) - .with_wait_after_typing(config.keyboard_wait_after_typing), + .with_wait_after_typing( + config.keyboard_scan.next_scan == crate::scan_preferences::NextScan::Wait, + ), keyboard_area: screen, mouse: crate::scan_mouse::MousePanel::new(mouse_options, 1, 100), mouse_area: screen, @@ -176,7 +178,9 @@ impl Workflow { cfg!(target_os = "macos"), self.point.config.keyboard_scan, ) - .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); + .with_wait_after_typing( + self.point.config.keyboard_scan.next_scan == crate::scan_preferences::NextScan::Wait, + ); keyboard.dock = self.dock; keyboard } @@ -622,7 +626,7 @@ impl Technique for Workflow { self.move_repeat = None; self.scroll_repeat = None; self.pending = None; - self.mouse.restart(); + self.mouse.repeat_stopped(); true } fn auto_selecting(&self) -> bool { @@ -988,6 +992,37 @@ mod tests { assert!(matches!(session.technique.phase(), Phase::Point(_))); } + #[test] + fn stopped_mouse_motion_waits_at_the_arrow_where_the_user_chose_to() { + use crate::scan_preferences::{NextScan, StartFrom}; + let mut config = Config::default(); + config.scan_preferences.mouse.next_scan = Some(NextScan::Wait); + config.scan_preferences.mouse.start_from = Some(StartFrom::Selection); + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut session = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); + session.action(Action::OpenMouse); + for action in [Action::Next, Action::Next, Action::Select, Action::Next] { + assert_eq!(session.action(action), None); + } + assert!(matches!( + session.action(Action::Select), + Some(Request::MouseMove { dy: 0, .. }) + )); + assert!(session.technique.switch_pressed()); + let status = |session: &Session| { + let mut frame = session.technique.frame(); + frame.tiles.pop().unwrap().text + }; + assert_eq!(status(&session), "Press Select to continue."); + session.tick(5000, false); + assert_eq!(session.action(Action::Select), None); + assert_eq!(status(&session), "Movement · Select →"); + } #[test] fn mouse_motion_stops_on_press_and_returns_to_first_row() { let screen = Rect { diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs index d2c01c05..0b600960 100644 --- a/src-tauri/src/scan_items.rs +++ b/src-tauri/src/scan_items.rs @@ -1,5 +1,5 @@ use crate::{ - scan_preferences::{Direction, Pattern, Resolved}, + scan_preferences::{Direction, NextScan, Pattern, Resolved, StartFrom}, scan_tree::{Navigator, Node, Selection}, scanning::{Action, Interval}, }; @@ -30,6 +30,8 @@ pub struct ItemScanner { policy: Policy, pending: bool, revision: u64, + /// Suspended by the user's choice to wait after a selection. + waiting: bool, } impl ItemScanner { pub fn new(nodes: Vec>, policy: Policy) -> Self { @@ -43,6 +45,7 @@ impl ItemScanner { policy, pending: false, revision: 0, + waiting: false, } } pub fn nodes(rows: &[Vec]) -> Vec> { @@ -121,6 +124,28 @@ impl ItemScanner { self.interval.reset(); self.cycles = 0; self.suspended = false; + self.waiting = false; + } + pub fn waiting(&self) -> bool { + self.waiting + } + pub fn stays_at_selection(&self) -> bool { + self.options.start_from == StartFrom::Selection + } + pub fn waits_after_selection(&self) -> bool { + self.options.next_scan == NextScan::Wait && self.options.automatic + } + /// Carries on after a selection that did something, as the user chose. + /// Left alone, a panel starts again from the beginning by itself. + pub fn continue_after_selection(&mut self) { + if self.stays_at_selection() { + self.pending = false; + self.restart_interval(); + } else { + self.restart(); + } + self.waiting = self.waits_after_selection(); + self.suspended = self.waiting; } pub fn reset_clock(&mut self) { self.interval.reset(); @@ -156,7 +181,9 @@ impl ItemScanner { } if self.suspended { if action == Action::Select { - if self.policy.resume_at_root { + if self.waiting && self.stays_at_selection() { + self.restart_interval(); + } else if self.policy.resume_at_root { self.restart(); } else { self.restart_interval(); @@ -283,6 +310,68 @@ mod tests { assert!(!scan.advance(499, 500)); } + #[test] + fn after_a_selection_scanning_continues_as_the_user_chose() { + let rows = vec![vec!["copy", "paste"], vec!["save", "close"]]; + for next_scan in [NextScan::Standard, NextScan::Automatic, NextScan::Wait] { + for start_from in [ + StartFrom::Standard, + StartFrom::Beginning, + StartFrom::Selection, + ] { + for automatic in [false, true] { + let mut scan = ItemScanner::configured_rows( + &rows, + Policy::KEYBOARD, + Resolved { + automatic, + next_scan, + start_from, + ..Default::default() + }, + ); + scan.handle(Action::Next); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.advance(499, 500); + assert_eq!(scan.handle(Action::Select), Some("close")); + scan.continue_after_selection(); + let stays = start_from == StartFrom::Selection; + let place = if stays { (1, Some(1)) } else { (0, None) }; + assert_eq!(scan.position(&rows), place); + let waits = automatic && next_scan == NextScan::Wait; + assert_eq!((scan.suspended, scan.waiting()), (waits, waits)); + if waits { + assert!(!scan.advance(500, 500)); + assert_eq!(scan.handle(Action::Next), None); + assert_eq!(scan.handle(Action::Select), None); + assert!(!scan.suspended && !scan.waiting()); + assert_eq!(scan.position(&rows), place); + } + assert!(!scan.advance(499, 500)); + assert!(scan.advance(1, 500)); + } + } + } + } + #[test] + fn a_pause_that_was_not_chosen_still_resumes_at_the_start() { + let mut scan = ItemScanner::configured_rows( + &[vec!["copy", "paste"], vec!["save", "close"]], + Policy::KEYBOARD, + Resolved { + next_scan: NextScan::Wait, + start_from: StartFrom::Selection, + ..Default::default() + }, + ); + scan.handle(Action::Next); + scan.handle(Action::Select); + scan.suspended = true; + scan.handle(Action::Select); + assert!(scan.nav.path().is_empty()); + assert_eq!(scan.nav.index(), 0); + } #[test] fn adapters_preserve_their_existing_resume_policy() { for policy in [Policy::MENU, Policy::KEYBOARD] { diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 24e23c26..c2d0a3a0 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -410,6 +410,17 @@ impl Keyboard { self.scan.restart(); self.skip_disabled(false); } + /// Carries on from the key just typed, where the user chose to stay. + /// Suggestions then do not pull the highlight to their row. + fn restart_after_typing(&mut self) { + if !self.scan.stays_at_selection() { + return self.restart(); + } + self.waiting_after_typing = false; + self.prefer_predictions = false; + self.scan.restart_interval(); + self.skip_disabled(false); + } pub fn advance(&mut self, ms: u64, period: u64) { if self.scan.advance(ms, period) { self.prefer_predictions = false; @@ -431,8 +442,12 @@ impl Keyboard { } if self.scan.suspended { if action == Action::Select { - self.error = false; - self.restart(); + if self.waiting_after_typing && !self.error { + self.restart_after_typing(); + } else { + self.error = false; + self.restart(); + } } return None; } @@ -641,7 +656,7 @@ impl Keyboard { Some(PendingTyped::Prediction) | None => {} } self.activation = None; - self.restart(); + self.restart_after_typing(); if self.wait_after_typing && self.scan.options.automatic { self.waiting_after_typing = true; self.prefer_predictions = false; @@ -1126,6 +1141,62 @@ mod tests { )); } #[test] + fn typing_continues_from_the_key_where_the_user_chose_to_stay() { + use crate::scan_preferences::{Resolved, StartFrom}; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let selected = |k: &Keyboard| { + let frame = k.frame(screen, 1.0, ScannerColor::default()); + let tiles: Vec<_> = frame.tiles.iter().filter(|t| t.selected).collect(); + assert_eq!(tiles.len(), 1); + tiles[0].text.clone() + }; + for wait in [false, true] { + let mut k = Keyboard::configured( + false, + Resolved { + start_from: StartFrom::Selection, + ..Default::default() + }, + ) + .with_wait_after_typing(wait); + k.enable_predictions(true); + k.predictions(None, false); + k.handle(Action::Select); + k.handle(Action::Next); + let key = selected(&k); + assert!(matches!(k.handle(Action::Select), Some(Output::Stroke(_)))); + assert!(k.succeeded()); + assert_eq!(k.waiting_after_typing, wait); + // Suggestions arriving do not move the highlight. + k.predictions( + Some(crate::prediction::worker::Batch { + token: 1, + words: vec!["hello".into()], + }), + false, + ); + if wait { + assert!(k.suspended()); + assert_eq!(k.handle(Action::Select), None); + assert!(!k.suspended()); + } + assert_eq!(selected(&k), key); + k.advance(999, 1000); + assert_eq!(selected(&k), key); + assert!(matches!(k.handle(Action::Select), Some(Output::Stroke(_)))); + // A failure starts again from the top. + k.failed(); + k.handle(Action::Select); + let frame = k.frame(screen, 1.0, ScannerColor::default()); + assert_eq!(frame.tiles.last().unwrap().text, "Letters · Select a row"); + } + } + #[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] { diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 051ea4e9..f9e2f12d 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -100,20 +100,22 @@ impl MousePanel { self.positioning = false; self.rebuild_rows(); } - _ => self.scan.restart(), + // The panel is left behind. + Key::Keyboard | Key::Close => self.scan.restart(), + _ => self.scan.continue_after_selection(), } } + /// A repeating move or scroll was stopped by a switch. + pub fn repeat_stopped(&mut self) { + self.scan.continue_after_selection(); + } fn rebuild_rows(&mut self) { self.rows = Self::rows(self.more, self.positioning, self.displays); self.scan = ItemScanner::configured_rows(&self.rows, Policy::KEYBOARD, self.scan.options); } pub fn handle(&mut self, action: Action) -> Option { - if self.scan.suspended { - if action == Action::Select { - self.error = false; - self.scan.restart(); - } - return None; + if self.scan.suspended && action == Action::Select { + self.error = false; } self.scan.handle(action) } @@ -206,6 +208,8 @@ impl MousePanel { .to_owned() } else if self.error { "Mouse action failed · Select to resume".to_owned() + } else if self.scan.waiting() { + "Press Select to continue.".to_owned() } else if self.scan.suspended { "Mouse paused · Select to resume".to_owned() } else if escaping { @@ -327,6 +331,53 @@ mod tests { ); } + #[test] + fn after_an_action_the_panel_continues_as_the_user_chose() { + use crate::scan_preferences::{NextScan, StartFrom}; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let status = |panel: &MousePanel| { + let mut frame = panel.frame(screen, 1.0, ScannerColor::default(), None); + frame.tiles.pop().unwrap().text + }; + let mut panel = MousePanel::new( + Resolved { + next_scan: NextScan::Wait, + start_from: StartFrom::Selection, + ..Default::default() + }, + 1, + 100, + ); + panel.handle(Action::Select); + panel.handle(Action::Next); + assert_eq!(panel.handle(Action::Select), Some(Key::RightClick)); + panel.choose(Key::RightClick); + assert!(panel.suspended()); + assert_eq!(status(&panel), "Press Select to continue."); + panel.advance(1000, 1000); + assert_eq!(panel.handle(Action::Select), None); + assert_eq!(status(&panel), "Movement · Select Right click"); + assert_eq!(panel.handle(Action::Select), Some(Key::RightClick)); + // A failure is not a choice, and starts again from the top. + panel.failed(); + assert_eq!(status(&panel), "Mouse action failed · Select to resume"); + panel.handle(Action::Select); + assert_eq!(status(&panel), "Movement · Select a row"); + // Changing page is not an action. + panel.choose(Key::More); + assert!(!panel.suspended()); + assert_eq!(status(&panel), "More controls · Select a row"); + // Nor is leaving the panel. + panel.handle(Action::Next); + panel.choose(Key::Keyboard); + assert!(!panel.suspended()); + assert_eq!(status(&panel), "More controls · Select a row"); + } #[test] fn panel_renders_with_selected_tiles_inside_the_screen() { let panel = MousePanel::new(Resolved::default(), 2, 100); From c00f965bab581137caf01f999f15189c982060c1 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:30:54 +0100 Subject: [PATCH 2/2] Start again from the top after a failure or a suggestion Review follow-up: a failed mouse action no longer resumes at the failed key, and a chosen suggestion no longer leaves the highlight in a row whose new suggestions are held back. Staying at a selection keeps the chosen direction, and a display change keeps a wait. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_workflow.rs | 12 ++++++++- src-tauri/src/scan_items.rs | 45 ++++++++++++++++++++++++++++++--- src-tauri/src/scan_keyboard.rs | 33 ++++++++++++++++++++---- src-tauri/src/scan_mouse.rs | 17 ++++++++++--- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 5daa6292..163dc218 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -1018,10 +1018,20 @@ mod tests { let mut frame = session.technique.frame(); frame.tiles.pop().unwrap().text }; - assert_eq!(status(&session), "Press Select to continue."); + assert_eq!(status(&session), "Mouse waiting · Select to continue"); session.tick(5000, false); assert_eq!(session.action(Action::Select), None); assert_eq!(status(&session), "Movement · Select →"); + // A move that fails starts again from the top. + assert!(session.action(Action::Select).is_some()); + session.technique.switch_pressed(); + session.action(Action::Select); + session.technique.mouse_repeat_enabled = false; + assert!(session.action(Action::Select).is_some()); + session.technique.execution_failed(String::new()); + assert_eq!(status(&session), "Mouse action failed · Select to resume"); + session.action(Action::Select); + assert_eq!(status(&session), "Movement · Select a row"); } #[test] fn mouse_motion_stops_on_press_and_returns_to_first_row() { diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs index 0b600960..bc67ff2d 100644 --- a/src-tauri/src/scan_items.rs +++ b/src-tauri/src/scan_items.rs @@ -139,13 +139,23 @@ impl ItemScanner { /// Left alone, a panel starts again from the beginning by itself. pub fn continue_after_selection(&mut self) { if self.stays_at_selection() { - self.pending = false; - self.restart_interval(); + self.restart_in_place(); } else { self.restart(); } - self.waiting = self.waits_after_selection(); - self.suspended = self.waiting; + if self.waits_after_selection() { + self.wait(); + } + } + /// Starts again from the highlighted item, in the chosen direction. + pub fn restart_in_place(&mut self) { + self.pending = false; + self.forward = self.options.direction == Direction::Forward; + self.restart_interval(); + } + pub fn wait(&mut self) { + self.waiting = true; + self.suspended = true; } pub fn reset_clock(&mut self) { self.interval.reset(); @@ -355,6 +365,33 @@ mod tests { } } #[test] + fn staying_at_a_selection_keeps_the_direction_and_pass_limit() { + let rows = vec![vec!["copy", "paste", "undo"], vec!["save", "close"]]; + let mut scan = ItemScanner::configured_rows( + &rows, + Policy::KEYBOARD, + Resolved { + start_from: StartFrom::Selection, + pass_limit: 1, + ..Default::default() + }, + ); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.handle(Action::Next); + scan.handle(Action::Back); + assert_eq!(scan.handle(Action::Select), Some("paste")); + scan.continue_after_selection(); + // A step back does not turn automatic scanning around. + assert!(scan.advance(500, 500)); + assert_eq!(scan.position(&rows), (0, Some(2))); + // Past the last item and the way out of the row is one pass. + assert!(scan.advance(500, 500)); + assert!(scan.nav.escaping() && !scan.suspended); + assert!(scan.advance(500, 500)); + assert!(scan.suspended && !scan.waiting()); + } + #[test] fn a_pause_that_was_not_chosen_still_resumes_at_the_start() { let mut scan = ItemScanner::configured_rows( &[vec!["copy", "paste"], vec!["save", "close"]], diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index c2d0a3a0..3baf9093 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -412,14 +412,15 @@ impl Keyboard { } /// Carries on from the key just typed, where the user chose to stay. /// Suggestions then do not pull the highlight to their row. + /// A suggestion is not stayed at: its row is about to be replaced, and + /// new suggestions are held back while the highlight is in it. fn restart_after_typing(&mut self) { - if !self.scan.stays_at_selection() { + if !self.scan.stays_at_selection() || self.prediction_row_active() { return self.restart(); } self.waiting_after_typing = false; self.prefer_predictions = false; - self.scan.restart_interval(); - self.skip_disabled(false); + self.scan.restart_in_place(); } pub fn advance(&mut self, ms: u64, period: u64) { if self.scan.advance(ms, period) { @@ -1192,8 +1193,30 @@ mod tests { // A failure starts again from the top. k.failed(); k.handle(Action::Select); - let frame = k.frame(screen, 1.0, ScannerColor::default()); - assert_eq!(frame.tiles.last().unwrap().text, "Letters · Select a row"); + let status = |k: &Keyboard| { + let mut frame = k.frame(screen, 1.0, ScannerColor::default()); + frame.tiles.pop().unwrap().text + }; + assert_eq!(status(&k), "Letters · Select a row"); + // So does a suggestion, whose row is replaced. + k.predictions( + Some(crate::prediction::worker::Batch { + token: 2, + words: vec!["hello".into()], + }), + false, + ); + assert!(k.prediction_row_active()); + k.handle(Action::Select); + assert!(matches!( + k.handle(Action::Select), + Some(Output::Prediction { .. }) + )); + assert!(k.succeeded()); + if wait { + k.handle(Action::Select); + } + assert_eq!(status(&k), "Letters · Select a row"); } } #[test] diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index f9e2f12d..9365bc1f 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -129,14 +129,20 @@ impl MousePanel { self.scan.suspended } pub fn failed(&mut self) { + self.scan.restart(); self.error = true; self.scan.suspended = true; } pub fn set_displays(&mut self, displays: usize) { if self.displays != displays { self.displays = displays; - if !self.positioning { + if !self.positioning && self.rows != Self::rows(self.more, false, displays) { + let (suspended, waiting) = (self.scan.suspended, self.scan.waiting()); self.rebuild_rows(); + self.scan.suspended = suspended; + if waiting { + self.scan.wait(); + } } } } @@ -209,7 +215,7 @@ impl MousePanel { } else if self.error { "Mouse action failed · Select to resume".to_owned() } else if self.scan.waiting() { - "Press Select to continue.".to_owned() + "Mouse waiting · Select to continue".to_owned() } else if self.scan.suspended { "Mouse paused · Select to resume".to_owned() } else if escaping { @@ -358,12 +364,17 @@ mod tests { assert_eq!(panel.handle(Action::Select), Some(Key::RightClick)); panel.choose(Key::RightClick); assert!(panel.suspended()); - assert_eq!(status(&panel), "Press Select to continue."); + assert_eq!(status(&panel), "Mouse waiting · Select to continue"); + // The monitor keys appearing does not end the wait. + panel.set_displays(2); + panel.set_displays(1); + assert_eq!(status(&panel), "Mouse waiting · Select to continue"); panel.advance(1000, 1000); assert_eq!(panel.handle(Action::Select), None); assert_eq!(status(&panel), "Movement · Select Right click"); assert_eq!(panel.handle(Action::Select), Some(Key::RightClick)); // A failure is not a choice, and starts again from the top. + panel.choose(Key::RightClick); panel.failed(); assert_eq!(status(&panel), "Mouse action failed · Select to resume"); panel.handle(Action::Select);