diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 7c8d877a..d72020eb 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -246,6 +246,13 @@ impl Adapter for PointScan { settings.scroll_repeat_interval_ms, settings.mouse_repeat_enabled, ); + } else if technique.follows_foreground() { + // There may be no window in front while one is coming forward. + match crate::scan_host::foreground() { + Ok(foreground) => environment.foreground = foreground, + Err(_) if technique.awaits_foreground() => return Ok(false), + Err(error) => return Err(error), + } } else if technique.keyboard_open() { crate::point_scan_ready(app)?; let foreground = crate::scan_host::foreground()?; diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 163dc218..66b0eaab 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -135,6 +135,10 @@ pub struct Workflow { elapsed: u64, pending: Option, error: Option, + /// A scan that started by itself, in which nothing has been chosen yet. + following: bool, + /// Times no window was in front of such a scan. + unseen: u64, } impl Workflow { pub fn new(config: PointSettings, screen: Rect, scale: f64) -> Result { @@ -171,6 +175,8 @@ impl Workflow { elapsed: 0, pending: None, error: None, + following: false, + unseen: 0, }) } fn new_keyboard(&self) -> crate::scan_keyboard::Keyboard { @@ -345,6 +351,37 @@ impl Workflow { self.menu = Menu::configured(kind, self.point.config.menu_scan); self.stage = Stage::Menu; } + /// The point has been used. The next scan waits for Select unless the + /// user chose to have it start by itself. + fn used(&mut self) { + if self.point.config.scan.next_scan == crate::scan_preferences::NextScan::Automatic { + self.new_point(); + self.following = true; + self.unseen = 0; + } else { + self.stage = Stage::Idle; + } + } + fn new_point(&mut self) { + self.parent_menu.clear(); + self.stage = Stage::Point; + self.point.start(); + } + /// What was just done may bring another window forward. Until something + /// is chosen in the scan that followed, no point depends on which. + pub fn follows_foreground(&mut self) -> bool { + let follows = self.following && self.stage == Stage::Point; + if !follows { + self.unseen = 0; + } + follows + } + /// No window is in front. Scanning holds still for up to a second in + /// all in case one is coming forward, and then ends as it always has. + pub fn awaits_foreground(&mut self) -> bool { + self.unseen += 1; + self.unseen * crate::scanning::TICK_MS <= 1000 + } fn restore_actions(&mut self) { if let Some(menu) = self.parent_menu.pop() { self.menu = menu; @@ -481,8 +518,10 @@ impl Workflow { .push(std::mem::replace(&mut self.menu, next)); } Item::Command(command) => { - if !command.stays_open() { - self.stage = Stage::Idle; + if command.stays_open() { + self.menu.continue_after_selection(); + } else { + self.used(); } return Some(Request::Command { command, @@ -497,7 +536,7 @@ impl Workflow { } Item::LeftClick | Item::RightClick | Item::DoubleClick => { - self.stage = Stage::Idle; + self.used(); return Some(if item == Item::LeftClick { default_click(self.source) } else { @@ -517,14 +556,10 @@ impl Workflow { self.point.start(); } } - Item::NewPoint => { - self.parent_menu.clear(); - self.stage = Stage::Point; - self.point.start(); - } + Item::NewPoint => self.new_point(), Item::Cancel => self.reset(), Item::Up | Item::Down | Item::Left | Item::Right => { - self.menu.restart_interval(); + self.menu.continue_after_selection(); let (dx, dy) = match item { Item::Up => (0, 3), Item::Down => (0, -3), @@ -570,6 +605,7 @@ impl Technique for Workflow { return; } self.pending = None; + self.following = false; self.stage = Stage::Menu; self.menu = Menu::configured(Kind::Actions, self.point.config.menu_scan); self.parent_menu.clear(); @@ -604,6 +640,7 @@ impl Technique for Workflow { self.parent_menu.clear(); self.pending = None; self.error = None; + self.following = false; self.elapsed = 0; self.source = (0, 0); self.destination = (0, 0); @@ -636,6 +673,7 @@ impl Technique for Workflow { self.stage != Stage::Executing } fn handle(&mut self, action: Action) -> Option { + self.following = false; if action == Action::OpenPoint { return self.open_point(); } @@ -769,7 +807,7 @@ impl Technique for Workflow { .min(self.point.config.auto_select_delay_ms); if self.elapsed == self.point.config.auto_select_delay_ms { self.pending = Some(default_click(self.source)); - self.stage = Stage::Idle; + self.used(); } } } else if self.stage == Stage::Executing { @@ -782,7 +820,7 @@ impl Technique for Workflow { .round() as i32, ); self.pending = Some(if self.elapsed == 300 { - self.stage = Stage::Idle; + self.used(); Request::DragEnd(p) } else { Request::DragMove(p) @@ -1034,6 +1072,186 @@ mod tests { assert_eq!(status(&session), "Movement ยท Select a row"); } #[test] + fn the_next_point_scan_starts_by_itself_where_the_user_chose_to() { + use crate::scan_preferences::NextScan; + for next_scan in [NextScan::Standard, NextScan::Wait, NextScan::Automatic] { + for auto_select in [false, true] { + let mut config = Config { + block_interval_ms: 250, + auto_select_enabled: auto_select, + auto_select_delay_ms: 500, + ..Default::default() + }; + config.scan_preferences.point.next_scan = Some(next_scan); + // The menu's own choice does not decide this. + config.scan_preferences.menu.next_scan = Some(NextScan::Automatic); + let screen = Rect { + x: -1000.0, + y: 20.0, + width: 1000.0, + height: 800.0, + }; + let mut s = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); + for _ in 0..3 { + assert_eq!(s.action(Action::Select), None); + } + let click = if auto_select { + (0..50).find_map(|_| { + s.tick(33, false); + s.take_selection() + }) + } else { + choose(&mut s, 0, 0) + }; + assert!( + matches!(click, Some(Request::Click { .. })), + "{next_scan:?} {click:?}" + ); + let again = next_scan == NextScan::Automatic; + assert_eq!(s.active(), again, "{next_scan:?}"); + assert_eq!(s.frame() != Frame::default(), again, "{next_scan:?}"); + assert_eq!(s.technique.follows_foreground(), again); + if again { + // No window in front is waited out, but not for ever. + let waited = (0..100) + .take_while(|_| s.technique.awaits_foreground()) + .count() as u64; + assert_eq!(waited, 1000 / crate::scanning::TICK_MS); + assert!(s.technique.follows_foreground()); + // Once something is chosen, the window is held to. + s.tick(33, false); + assert!(s.technique.follows_foreground()); + s.action(Action::Select); + assert!(!s.technique.follows_foreground()); + s.action(Action::Cancel); + s.action(Action::Select); + assert!(!s.technique.follows_foreground()); + assert_eq!( + s.technique.phase(), + Phase::Point(crate::point_scan::Phase::X) + ); + // A click that failed is still reported. + s.technique.execution_failed("failed".into()); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::MenuSuspended) + ); + s.action(Action::Select); + s.technique.selected(Item::NewPoint); + // And the pass limit still stops it. + for _ in 0..400 { + s.tick(250, false); + } + assert_eq!(s.frame(), Frame::default()); + } + } + } + } + #[test] + fn a_drag_or_a_closing_command_is_followed_by_the_next_point_scan() { + use crate::scan_preferences::NextScan; + for next_scan in [NextScan::Standard, NextScan::Automatic] { + let again = next_scan == NextScan::Automatic; + let mut config = Config { + automatic: false, + block_interval_ms: 250, + ..Default::default() + }; + config.scan_preferences.point.next_scan = Some(next_scan); + let screen = Rect { + x: -1000.0, + y: 20.0, + width: 1000.0, + height: 800.0, + }; + let new = || Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), false); + let mut s = new(); + open(&mut s); + choose(&mut s, 1, 1); + for action in [Action::Next, Action::Select, Action::Next, Action::Select] { + s.action(action); + } + assert!(matches!(choose(&mut s, 0, 0), Some(Request::DragStart(_)))); + s.tick(150, false); + assert!(matches!(s.take_selection(), Some(Request::DragMove(_)))); + s.tick(150, false); + assert!(matches!(s.take_selection(), Some(Request::DragEnd(_)))); + assert_eq!(s.active(), again); + assert_eq!(s.technique.follows_foreground(), again); + if again { + assert_eq!( + s.technique.phase(), + Phase::Point(crate::point_scan::Phase::X) + ); + // The menus of the drag are not returned to. + assert!(s.technique.parent_menu.is_empty()); + } + let mut s = new(); + open(&mut s); + let copy = Item::Command(crate::scan_menu::Command::Copy); + assert!(matches!( + s.technique.selected(copy), + Some(Request::Command { .. }) + )); + s.take_selection(); + assert_eq!(s.active(), again); + // A command that leaves the menu open starts nothing. + let mut s = new(); + open(&mut s); + let mute = Item::Command(crate::scan_menu::Command::Mute); + let before = s.frame(); + assert!(s.technique.selected(mute).is_some()); + assert_eq!(s.technique.phase(), Phase::Workflow(WorkflowPhase::Menu)); + assert_eq!(s.frame(), before); + } + } + #[test] + fn scrolling_continues_in_the_menu_as_the_user_chose() { + use crate::scan_preferences::{NextScan, StartFrom}; + for (next_scan, start_from) in [ + (NextScan::Standard, StartFrom::Standard), + (NextScan::Wait, StartFrom::Selection), + (NextScan::Wait, StartFrom::Beginning), + (NextScan::Automatic, StartFrom::Beginning), + ] { + let mut config = Config { + automatic: true, + block_interval_ms: 250, + ..Default::default() + }; + config.scan_preferences.menu.next_scan = Some(next_scan); + config.scan_preferences.menu.start_from = Some(start_from); + let screen = Rect { + x: -1000.0, + y: 20.0, + width: 1000.0, + height: 800.0, + }; + let mut s = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); + open(&mut s); + choose(&mut s, 1, 0); + assert!(matches!(choose(&mut s, 0, 1), Some(Request::Scroll { .. }))); + let waits = next_scan == NextScan::Wait; + assert_eq!( + s.technique.phase(), + Phase::Workflow(if waits { + WorkflowPhase::MenuSuspended + } else { + WorkflowPhase::Menu + }) + ); + if waits { + s.tick(5000, false); + assert_eq!(s.action(Action::Select), None); + } + assert_eq!( + matches!(s.action(Action::Select), Some(Request::Scroll { .. })), + start_from != StartFrom::Beginning, + "{start_from:?}" + ); + } + } + #[test] fn mouse_motion_stops_on_press_and_returns_to_first_row() { let screen = Rect { x: 0.0, diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs index bc67ff2d..288ef032 100644 --- a/src-tauri/src/scan_items.rs +++ b/src-tauri/src/scan_items.rs @@ -129,19 +129,26 @@ impl ItemScanner { pub fn waiting(&self) -> bool { self.waiting } + /// Left alone, a menu stays where it is and a panel starts again. pub fn stays_at_selection(&self) -> bool { - self.options.start_from == StartFrom::Selection + match self.options.start_from { + StartFrom::Standard => !self.policy.resume_at_root, + StartFrom::Beginning => false, + StartFrom::Selection => true, + } } 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 { + if !self.stays_at_selection() { self.restart(); + } else if self.options.start_from == StartFrom::Standard { + // A menu left alone also keeps the direction it was turned to. + self.restart_interval(); + } else { + self.restart_in_place(); } if self.waits_after_selection() { self.wait(); @@ -365,6 +372,62 @@ mod tests { } } #[test] + fn a_menu_left_alone_stays_at_the_selection() { + let rows = vec![vec!["copy", "paste"], vec!["save", "close"]]; + for (start_from, place) in [ + (StartFrom::Standard, (1, Some(1))), + (StartFrom::Selection, (1, Some(1))), + (StartFrom::Beginning, (0, None)), + ] { + for wait in [false, true] { + let mut scan = ItemScanner::configured_rows( + &rows, + Policy::MENU, + Resolved { + start_from, + next_scan: if wait { + NextScan::Wait + } else { + NextScan::Standard + }, + ..Default::default() + }, + ); + scan.handle(Action::Next); + scan.handle(Action::Select); + scan.handle(Action::Next); + assert_eq!(scan.handle(Action::Select), Some("close")); + scan.continue_after_selection(); + assert_eq!(scan.position(&rows), place); + assert_eq!(scan.waiting(), wait); + if wait { + assert_eq!(scan.handle(Action::Select), None); + assert_eq!(scan.position(&rows), place); + } + assert!(!scan.advance(499, 500)); + assert!(scan.advance(1, 500)); + } + } + // Left alone, it goes on in the direction it was turned to. + for (start_from, turned) in [(StartFrom::Standard, true), (StartFrom::Selection, false)] { + let mut scan = ItemScanner::configured_rows( + &rows, + Policy::MENU, + Resolved { + start_from, + ..Default::default() + }, + ); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.handle(Action::Reverse); + assert_eq!(scan.handle(Action::Select), Some("paste")); + scan.continue_after_selection(); + scan.advance(500, 500); + assert_eq!(scan.position(&rows), (0, turned.then_some(0))); + } + } + #[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( diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index bde47754..eecdd9f0 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -135,6 +135,10 @@ impl Menu { pub fn restart_interval(&mut self) { self.scan.restart_interval(); } + /// An item that leaves the menu open was chosen. + pub fn continue_after_selection(&mut self) { + self.scan.continue_after_selection(); + } pub fn advance(&mut self, ms: u64) { self.scan.advance(ms, self.period); } @@ -182,7 +186,9 @@ impl Menu { }); } } - let text = if self.suspended() { + let text = if self.scan.waiting() { + "Select to continue" + } else if self.suspended() { "Select to resume" } else if self.scan.nav.escaping() { "Back to rows"