From 135834db7875d7d4369f4918481fec9bd589e8bb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:37:47 +0100 Subject: [PATCH 1/4] Apply after-selection preferences in the action menu and point scan After a click, a closing command or a drag, the next point scan starts by itself when the point scanner's Next scan is automatic. Scroll and media items, which leave the menu open, follow the menu's choices. Left alone, both behave as before. Closes #932 Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_workflow.rs | 133 ++++++++++++++++++++++++++++++-- src-tauri/src/scan_items.rs | 46 ++++++++++- src-tauri/src/scan_menu.rs | 8 +- 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 163dc218..510775cb 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -345,6 +345,17 @@ 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.parent_menu.clear(); + self.stage = Stage::Point; + self.point.start(); + } else { + self.stage = Stage::Idle; + } + } fn restore_actions(&mut self) { if let Some(menu) = self.parent_menu.pop() { self.menu = menu; @@ -481,8 +492,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 +510,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 { @@ -524,7 +537,7 @@ impl Workflow { } 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), @@ -769,7 +782,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 +795,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 +1047,114 @@ 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 { + let click = s.technique.selected(Item::LeftClick); + s.take_selection(); + click + }; + assert!( + matches!(click, Some(Request::Click { .. })), + "{next_scan:?} {click:?}" + ); + let again = next_scan == NextScan::Automatic; + assert_eq!(s.frame() != Frame::default(), again, "{next_scan:?}"); + if again { + 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 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..4c5fecb5 100644 --- a/src-tauri/src/scan_items.rs +++ b/src-tauri/src/scan_items.rs @@ -129,14 +129,18 @@ 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(); @@ -365,6 +369,44 @@ 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)); + } + } + } + #[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" From 25bc1b55461ceab2faa1294b5f1366f0a9ac9719 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:42:43 +0100 Subject: [PATCH 2/4] Let a scan that started by itself follow the window brought forward Review follow-up: a click may bring another window forward, which used to end a scan in progress. A scan that followed a click by itself now takes up the window in front until something is chosen in it. A menu left alone keeps the direction it was turned to. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_scan_runtime.rs | 2 + src-tauri/src/point_workflow.rs | 98 +++++++++++++++++++++++++---- src-tauri/src/scan_items.rs | 27 +++++++- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 7c8d877a..8b6c2218 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -246,6 +246,8 @@ impl Adapter for PointScan { settings.scroll_repeat_interval_ms, settings.mouse_repeat_enabled, ); + } else if technique.follows_foreground() { + environment.foreground = crate::scan_host::foreground()?; } 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 510775cb..a26780b4 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -135,6 +135,8 @@ pub struct Workflow { elapsed: u64, pending: Option, error: Option, + /// A scan that started by itself, in which nothing has been chosen yet. + following: bool, } impl Workflow { pub fn new(config: PointSettings, screen: Rect, scale: f64) -> Result { @@ -171,6 +173,7 @@ impl Workflow { elapsed: 0, pending: None, error: None, + following: false, }) } fn new_keyboard(&self) -> crate::scan_keyboard::Keyboard { @@ -349,13 +352,22 @@ impl Workflow { /// 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.parent_menu.clear(); - self.stage = Stage::Point; - self.point.start(); + self.new_point(); + self.following = true; } 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(&self) -> bool { + self.following && self.stage == Stage::Point + } fn restore_actions(&mut self) { if let Some(menu) = self.parent_menu.pop() { self.menu = menu; @@ -530,11 +542,7 @@ 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.continue_after_selection(); @@ -617,6 +625,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); @@ -649,6 +658,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(); } @@ -1076,17 +1086,25 @@ mod tests { s.take_selection() }) } else { - let click = s.technique.selected(Item::LeftClick); - s.take_selection(); - click + 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 { + // 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) @@ -1109,6 +1127,64 @@ mod tests { } } #[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 [ diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs index 4c5fecb5..288ef032 100644 --- a/src-tauri/src/scan_items.rs +++ b/src-tauri/src/scan_items.rs @@ -142,10 +142,13 @@ impl ItemScanner { } /// Carries on after a selection that did something, as the user chose. 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(); @@ -405,6 +408,24 @@ mod tests { 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() { From d038cc322cc1a60616b8250f3c8afce696a2e233 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:46:15 +0100 Subject: [PATCH 3/4] Wait out a moment with no window in front Review follow-up: while another window is coming forward there may be none in front. A scan that started by itself now skips that moment instead of ending. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_scan_runtime.rs | 6 +++++- src-tauri/src/point_workflow.rs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 8b6c2218..7f4afbca 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -247,7 +247,11 @@ impl Adapter for PointScan { settings.mouse_repeat_enabled, ); } else if technique.follows_foreground() { - environment.foreground = crate::scan_host::foreground()?; + // There may be no window in front while one is coming forward. + let Ok(foreground) = crate::scan_host::foreground() else { + return Ok(false); + }; + environment.foreground = foreground; } 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 a26780b4..138fe616 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -591,6 +591,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(); From 57e84e529497ec3e6c4c044d7b0311bfe8832b92 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:49:05 +0100 Subject: [PATCH 4/4] Hold still for at most a second with no window in front Review follow-up: the wait for a window to come forward is bounded, so scanning cannot freeze. After a second it ends with the usual message. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_scan_runtime.rs | 9 +++++---- src-tauri/src/point_workflow.rs | 24 ++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 7f4afbca..d72020eb 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -248,10 +248,11 @@ impl Adapter for PointScan { ); } else if technique.follows_foreground() { // There may be no window in front while one is coming forward. - let Ok(foreground) = crate::scan_host::foreground() else { - return Ok(false); - }; - environment.foreground = foreground; + 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 138fe616..66b0eaab 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -137,6 +137,8 @@ pub struct Workflow { 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 { @@ -174,6 +176,7 @@ impl Workflow { pending: None, error: None, following: false, + unseen: 0, }) } fn new_keyboard(&self) -> crate::scan_keyboard::Keyboard { @@ -354,6 +357,7 @@ impl Workflow { 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; } @@ -365,8 +369,18 @@ impl Workflow { } /// 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(&self) -> bool { - self.following && self.stage == Stage::Point + 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() { @@ -1098,6 +1112,12 @@ mod tests { 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());