From 4031f57a2fcc516845cbeb0547bf6bb5667acf87 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 15 Sep 2026 17:10:45 +0100 Subject: [PATCH] feat: add Close menu to every scanning page --- docs/point-scan.md | 6 +- src-tauri/src/point_workflow.rs | 40 +++++++++++- src-tauri/src/scan_menu.rs | 104 ++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 10 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index da9dc2f..2308feb 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -40,11 +40,11 @@ Physical validation should include forward/reverse escape with a real switch, pa ## Actions at a point -Choosing a point opens a native grid beside its marker. The rows are Left click / Right click / Double click; Scroll / Drag; Choose another point / Cancel. The shared tree navigator handles row/item selection and row escape. The menu uses the current automatic/manual mode and block interval. After three automatic passes, it retains the target and shows Select to resume. That Select resumes scanning without executing an action. +Choosing a point opens a native grid beside its marker. The rows are Left click / Right click / Double click; Scroll / Drag / More; New point / Close menu. The shared tree navigator handles row/item selection and row escape. The menu uses the current automatic/manual mode and block interval. After three automatic passes, it retains the target and shows Select to resume. That Select resumes scanning without executing an action. Scroll offers Up / Down, Left / Right, and Back to actions. Each selection moves the pointer to the target and sends three wheel steps; the selected direction remains available. Back restores the main menu's Scroll item. Choose another point immediately restarts the configured technique on the original display. Clicks and completed drags return to armed idle. -Drag retains the source and scans a destination on the same display without holding a button. The confirmation menu offers Drag here, Choose destination again and Cancel drag. Cancel drag returns to the source menu. Confirmation moves to the source, presses the left button, interpolates to the destination over 300 ms on runtime ticks, and releases. No blocking sleep is used. Ordinary selections cannot issue another action during execution. Failed releases stay owned and cleanup retries before rearming and on disabled ticks. +Drag retains the source and scans a destination on the same display without holding a button. The confirmation menu offers Drag here and New destination, with Cancel drag and Close menu in its final row. Cancel drag returns to the source menu. Confirmation moves to the source, presses the left button, interpolates to the destination over 300 ms on runtime ticks, and releases. No blocking sleep is used. Ordinary selections cannot issue another action during execution. Failed releases stay owned and cleanup retries before rearming and on disabled ticks. `point_workflow` separates point selection, menu navigation and typed action requests. Its current selection policy always opens the menu; a future auto-select policy can reuse `default_click` without changing the executor. No auto-select timer or setting exists. The reusable `scan_menu` defines stable action IDs and rows. Shared frames carry menu tiles, labels and highlight strips; all native windows remain click-through and nonactivating. @@ -59,3 +59,5 @@ Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. R Start forwarding, then press Select to begin. Local switch keys are inactive during the remote session; PC Escape remains an emergency stop. The PC owns hold-action timing: hold actions work exactly as for keyboard switches, and holding a remote switch past the PC emergency limit resets the scan without ending the session. Remote's own Forwarding hold-to-stop (3, 5 or 8 seconds, set on the phone) still ends the session without selecting, so keep hold lists short enough to finish inside it. Edits to remote switches, shared scanner settings or hold timing apply to a live session on the next press; a change that leaves the current mode without its required actions ends the session. The PC no longer rejects a stale profile revision, so Remote only needs to reload profiles to refresh its labels. Cancelled edges, missing edges and sync mismatches never select. Held-state mismatches cancel the gesture and require neutral input. Sessions expire after five seconds without an authenticated edge or sync. Disconnects and safety stops discard the point and release drag buttons; start explicitly again. Ordinary forwarding and remote scanning cannot own input simultaneously. Local scanning resumes after Remote disconnects. + +Every scanning-menu page ends with a navigation row containing Close menu. Subpages retain Back, the main page retains New point, and drag confirmation retains Cancel drag. Close menu ends the current scan and clears its points, pending selection and parent-page history without issuing desktop input. Select starts a fresh scan afterward. Pages use up to three columns and four rows; native layout scales the entire menu to fit the display. Pause/resume and emergency-stop behavior are unchanged. diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index f14d1f5..0021ce5 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -178,7 +178,7 @@ impl Workflow { self.stage = Stage::Point; self.point.start(); } - Item::Cancel => self.stage = Stage::Idle, + Item::Cancel => self.reset(), Item::Up | Item::Down | Item::Left | Item::Right => { self.menu.restart_interval(); let (dx, dy) = match item { @@ -482,6 +482,41 @@ mod tests { } s.action(Action::Select) } + #[test] + fn closing_every_page_clears_nested_workflow_and_waits_for_a_new_select() { + for automatic in [false, true] { + for kind in crate::scan_menu::ALL_MENU_KINDS { + let mut s = session(automatic); + open(&mut s); + s.technique.selected(Item::More); + s.technique.selected(Item::Group(Kind::Browser)); + s.technique.open(kind); + s.technique.destination = (40, 50); + s.technique.elapsed = 123; + s.technique.pending = Some(default_click((10, 20))); + let tiles = s.frame().tiles; + let rows = tiles + .iter() + .filter(|tile| tile.rect.x == tiles[0].rect.x) + .count(); + assert_eq!(choose(&mut s, rows - 1, 1), None, "{kind:?}"); + assert!(!s.active()); + assert!(s.technique.stage == Stage::Idle); + assert!(s.technique.parent_menu.is_empty()); + assert!(s.technique.pending.is_none()); + assert_eq!(s.technique.source, (0, 0)); + assert_eq!(s.technique.destination, (0, 0)); + assert_eq!(s.technique.elapsed, 0); + assert!(s.frame().tiles.is_empty()); + s.tick(5000, false); + assert_eq!(s.take_selection(), None); + assert!(!s.active()); + assert_eq!(s.action(Action::Select), None); + assert!(s.active()); + assert!(s.technique.stage == Stage::Point); + } + } + } fn auto_session( mode: crate::point_scan::Mode, automatic: bool, @@ -754,6 +789,7 @@ mod tests { s.action(Action::Next); s.action(Action::Next); assert_eq!(s.action(Action::Select), None); + assert_eq!(s.action(Action::Select), None); assert_eq!(s.technique.menu.kind, Kind::Actions); assert_eq!(s.action(Action::Select), None); assert_eq!(s.technique.menu.kind, Kind::Scroll); @@ -866,7 +902,7 @@ mod tests { ); s.action(Action::Select); s.action(Action::Select); - assert_eq!(choose(&mut s, 0, 2), None); + assert_eq!(choose(&mut s, 1, 0), None); assert_eq!(s.technique.menu.kind, Kind::Actions); assert_eq!(s.take_selection(), None); } diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index 44ce5c5..001b4a5 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -45,7 +45,7 @@ impl Item { Self::Scroll => "Scroll", Self::Drag => "Drag", Self::NewPoint => "New point", - Self::Cancel => "Cancel", + Self::Cancel => "Close menu", Self::Up => "Up", Self::Down => "Down", Self::Left => "Left", @@ -73,6 +73,22 @@ pub enum Kind { Scroll, ConfirmDrag, } +#[cfg(test)] +pub(crate) const ALL_MENU_KINDS: [Kind; 13] = [ + Kind::Actions, + Kind::More, + Kind::Mouse, + Kind::Editing, + Kind::Windows, + Kind::Browser, + Kind::Tabs, + Kind::Zoom, + Kind::Media, + Kind::Displays, + Kind::Scanning, + Kind::Scroll, + Kind::ConfirmDrag, +]; pub struct Menu { pub kind: Kind, rows: Vec>, @@ -435,8 +451,10 @@ impl Kind { vec![NewPoint, Cancel], ] } - Self::Scroll => return vec![vec![Up, Down], vec![Left, Right], vec![Back]], - Self::ConfirmDrag => return vec![vec![DragHere, DestinationAgain, CancelDrag]], + Self::Scroll => return vec![vec![Up, Down], vec![Left, Right], vec![Back, Cancel]], + Self::ConfirmDrag => { + return vec![vec![DragHere, DestinationAgain], vec![CancelDrag, Cancel]] + } Self::More => vec![ Group(Self::Mouse), Group(Self::Editing), @@ -522,7 +540,10 @@ impl Kind { Back, ], }; - items.chunks(3).map(|row| row.to_vec()).collect() + let actions: Vec<_> = items.into_iter().filter(|item| *item != Back).collect(); + let mut rows: Vec<_> = actions.chunks(3).map(|row| row.to_vec()).collect(); + rows.push(vec![Back, Cancel]); + rows } } @@ -530,7 +551,78 @@ impl Kind { mod tests { use super::*; #[test] - fn every_submenu_is_reachable_and_fits_a_three_by_three_grid() { + fn every_page_has_one_close_tile_in_its_final_navigation_row() { + for kind in ALL_MENU_KINDS { + let rows = kind.rows(); + let back = match kind { + Kind::Actions => Item::NewPoint, + Kind::ConfirmDrag => Item::CancelDrag, + _ => Item::Back, + }; + assert_eq!(rows.last().unwrap(), &[back, Item::Cancel], "{kind:?}"); + assert_eq!( + rows.iter() + .flatten() + .filter(|item| **item == Item::Cancel) + .count(), + 1 + ); + assert!(rows.len() <= 4 && rows.iter().all(|row| row.len() <= 3)); + assert_eq!(Item::Cancel.label(), "Close menu"); + } + } + + #[test] + fn close_tile_is_reachable_by_automatic_row_and_item_scanning_on_every_page() { + for kind in ALL_MENU_KINDS { + let mut menu = Menu::new(kind, 250); + for _ in 1..kind.rows().len() { + menu.advance(250); + } + assert_eq!(menu.handle(Action::Select), None); + menu.advance(250); + assert_eq!(menu.handle(Action::Select), Some(Item::Cancel), "{kind:?}"); + } + } + + #[test] + fn every_page_including_four_row_pages_fits_small_scaled_displays() { + for kind in ALL_MENU_KINDS { + for (width, height) in [(320., 240.), (1920., 1080.)] { + let screen = Rect { + x: -1920., + y: -200., + width, + height, + }; + for units in [1., 1.5, 2.] { + for point in [ + (-1920, -200), + ( + (screen.x + width - 1.) as i32, + (screen.y + height - 1.) as i32, + ), + ] { + let frame = Menu::new(kind, 250).frame(point, screen, units); + assert!(frame.tiles.iter().any(|tile| tile.text == "Close menu")); + for rect in frame + .tiles + .iter() + .map(|tile| tile.rect) + .chain(frame.label.iter().map(|label| label.rect)) + { + assert!(rect.width > 0. && rect.height > 0.); + assert!(rect.x >= screen.x && rect.y >= screen.y); + assert!(rect.x + rect.width <= screen.x + screen.width + 0.001); + assert!(rect.y + rect.height <= screen.y + screen.height + 0.001); + } + } + } + } + } + } + #[test] + fn every_submenu_is_reachable_and_fits_three_columns_and_four_rows() { let mut pending = vec![Kind::Actions]; let mut visited = vec![]; let mut commands = vec![]; @@ -540,7 +632,7 @@ mod tests { } visited.push(kind); let rows = kind.rows(); - assert!(rows.len() <= 3); + assert!(rows.len() <= 4); assert!(rows.iter().all(|r| r.len() <= 3)); if kind != Kind::Actions { assert!(rows.iter().flatten().any(|i| *i == Item::Back));