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..163dc218 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,47 @@ 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), "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() { let screen = Rect { diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs index d2c01c05..bc67ff2d 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,38 @@ 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.restart_in_place(); + } else { + self.restart(); + } + 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(); @@ -156,7 +191,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 +320,95 @@ 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 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"]], + 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..3baf9093 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -410,6 +410,18 @@ 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. + /// 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() || self.prediction_row_active() { + return self.restart(); + } + self.waiting_after_typing = false; + self.prefer_predictions = false; + self.scan.restart_in_place(); + } pub fn advance(&mut self, ms: u64, period: u64) { if self.scan.advance(ms, period) { self.prefer_predictions = false; @@ -431,8 +443,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 +657,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 +1142,84 @@ 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 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] 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..9365bc1f 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) } @@ -127,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(); + } } } } @@ -206,6 +214,8 @@ impl MousePanel { .to_owned() } else if self.error { "Mouse action failed · Select to resume".to_owned() + } else if self.scan.waiting() { + "Mouse waiting · Select to continue".to_owned() } else if self.scan.suspended { "Mouse paused · Select to resume".to_owned() } else if escaping { @@ -327,6 +337,58 @@ 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), "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); + 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);