Skip to content

Commit e78f5e3

Browse files
feat: add Close menu to every scanning page (#783)
Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent b22b473 commit e78f5e3

3 files changed

Lines changed: 140 additions & 10 deletions

File tree

docs/point-scan.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ Physical validation should include forward/reverse escape with a real switch, pa
4040

4141
## Actions at a point
4242

43-
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.
43+
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.
4444

4545
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.
4646

47-
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.
47+
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.
4848

4949
`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.
5050

@@ -59,3 +59,5 @@ Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. R
5959
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.
6060

6161
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.
62+
63+
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.

src-tauri/src/point_workflow.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ impl Workflow {
178178
self.stage = Stage::Point;
179179
self.point.start();
180180
}
181-
Item::Cancel => self.stage = Stage::Idle,
181+
Item::Cancel => self.reset(),
182182
Item::Up | Item::Down | Item::Left | Item::Right => {
183183
self.menu.restart_interval();
184184
let (dx, dy) = match item {
@@ -482,6 +482,41 @@ mod tests {
482482
}
483483
s.action(Action::Select)
484484
}
485+
#[test]
486+
fn closing_every_page_clears_nested_workflow_and_waits_for_a_new_select() {
487+
for automatic in [false, true] {
488+
for kind in crate::scan_menu::ALL_MENU_KINDS {
489+
let mut s = session(automatic);
490+
open(&mut s);
491+
s.technique.selected(Item::More);
492+
s.technique.selected(Item::Group(Kind::Browser));
493+
s.technique.open(kind);
494+
s.technique.destination = (40, 50);
495+
s.technique.elapsed = 123;
496+
s.technique.pending = Some(default_click((10, 20)));
497+
let tiles = s.frame().tiles;
498+
let rows = tiles
499+
.iter()
500+
.filter(|tile| tile.rect.x == tiles[0].rect.x)
501+
.count();
502+
assert_eq!(choose(&mut s, rows - 1, 1), None, "{kind:?}");
503+
assert!(!s.active());
504+
assert!(s.technique.stage == Stage::Idle);
505+
assert!(s.technique.parent_menu.is_empty());
506+
assert!(s.technique.pending.is_none());
507+
assert_eq!(s.technique.source, (0, 0));
508+
assert_eq!(s.technique.destination, (0, 0));
509+
assert_eq!(s.technique.elapsed, 0);
510+
assert!(s.frame().tiles.is_empty());
511+
s.tick(5000, false);
512+
assert_eq!(s.take_selection(), None);
513+
assert!(!s.active());
514+
assert_eq!(s.action(Action::Select), None);
515+
assert!(s.active());
516+
assert!(s.technique.stage == Stage::Point);
517+
}
518+
}
519+
}
485520
fn auto_session(
486521
mode: crate::point_scan::Mode,
487522
automatic: bool,
@@ -754,6 +789,7 @@ mod tests {
754789
s.action(Action::Next);
755790
s.action(Action::Next);
756791
assert_eq!(s.action(Action::Select), None);
792+
assert_eq!(s.action(Action::Select), None);
757793
assert_eq!(s.technique.menu.kind, Kind::Actions);
758794
assert_eq!(s.action(Action::Select), None);
759795
assert_eq!(s.technique.menu.kind, Kind::Scroll);
@@ -866,7 +902,7 @@ mod tests {
866902
);
867903
s.action(Action::Select);
868904
s.action(Action::Select);
869-
assert_eq!(choose(&mut s, 0, 2), None);
905+
assert_eq!(choose(&mut s, 1, 0), None);
870906
assert_eq!(s.technique.menu.kind, Kind::Actions);
871907
assert_eq!(s.take_selection(), None);
872908
}

src-tauri/src/scan_menu.rs

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ impl Item {
4545
Self::Scroll => "Scroll",
4646
Self::Drag => "Drag",
4747
Self::NewPoint => "New point",
48-
Self::Cancel => "Cancel",
48+
Self::Cancel => "Close menu",
4949
Self::Up => "Up",
5050
Self::Down => "Down",
5151
Self::Left => "Left",
@@ -73,6 +73,22 @@ pub enum Kind {
7373
Scroll,
7474
ConfirmDrag,
7575
}
76+
#[cfg(test)]
77+
pub(crate) const ALL_MENU_KINDS: [Kind; 13] = [
78+
Kind::Actions,
79+
Kind::More,
80+
Kind::Mouse,
81+
Kind::Editing,
82+
Kind::Windows,
83+
Kind::Browser,
84+
Kind::Tabs,
85+
Kind::Zoom,
86+
Kind::Media,
87+
Kind::Displays,
88+
Kind::Scanning,
89+
Kind::Scroll,
90+
Kind::ConfirmDrag,
91+
];
7692
pub struct Menu {
7793
pub kind: Kind,
7894
rows: Vec<Vec<Item>>,
@@ -435,8 +451,10 @@ impl Kind {
435451
vec![NewPoint, Cancel],
436452
]
437453
}
438-
Self::Scroll => return vec![vec![Up, Down], vec![Left, Right], vec![Back]],
439-
Self::ConfirmDrag => return vec![vec![DragHere, DestinationAgain, CancelDrag]],
454+
Self::Scroll => return vec![vec![Up, Down], vec![Left, Right], vec![Back, Cancel]],
455+
Self::ConfirmDrag => {
456+
return vec![vec![DragHere, DestinationAgain], vec![CancelDrag, Cancel]]
457+
}
440458
Self::More => vec![
441459
Group(Self::Mouse),
442460
Group(Self::Editing),
@@ -522,15 +540,89 @@ impl Kind {
522540
Back,
523541
],
524542
};
525-
items.chunks(3).map(|row| row.to_vec()).collect()
543+
let actions: Vec<_> = items.into_iter().filter(|item| *item != Back).collect();
544+
let mut rows: Vec<_> = actions.chunks(3).map(|row| row.to_vec()).collect();
545+
rows.push(vec![Back, Cancel]);
546+
rows
526547
}
527548
}
528549

529550
#[cfg(test)]
530551
mod tests {
531552
use super::*;
532553
#[test]
533-
fn every_submenu_is_reachable_and_fits_a_three_by_three_grid() {
554+
fn every_page_has_one_close_tile_in_its_final_navigation_row() {
555+
for kind in ALL_MENU_KINDS {
556+
let rows = kind.rows();
557+
let back = match kind {
558+
Kind::Actions => Item::NewPoint,
559+
Kind::ConfirmDrag => Item::CancelDrag,
560+
_ => Item::Back,
561+
};
562+
assert_eq!(rows.last().unwrap(), &[back, Item::Cancel], "{kind:?}");
563+
assert_eq!(
564+
rows.iter()
565+
.flatten()
566+
.filter(|item| **item == Item::Cancel)
567+
.count(),
568+
1
569+
);
570+
assert!(rows.len() <= 4 && rows.iter().all(|row| row.len() <= 3));
571+
assert_eq!(Item::Cancel.label(), "Close menu");
572+
}
573+
}
574+
575+
#[test]
576+
fn close_tile_is_reachable_by_automatic_row_and_item_scanning_on_every_page() {
577+
for kind in ALL_MENU_KINDS {
578+
let mut menu = Menu::new(kind, 250);
579+
for _ in 1..kind.rows().len() {
580+
menu.advance(250);
581+
}
582+
assert_eq!(menu.handle(Action::Select), None);
583+
menu.advance(250);
584+
assert_eq!(menu.handle(Action::Select), Some(Item::Cancel), "{kind:?}");
585+
}
586+
}
587+
588+
#[test]
589+
fn every_page_including_four_row_pages_fits_small_scaled_displays() {
590+
for kind in ALL_MENU_KINDS {
591+
for (width, height) in [(320., 240.), (1920., 1080.)] {
592+
let screen = Rect {
593+
x: -1920.,
594+
y: -200.,
595+
width,
596+
height,
597+
};
598+
for units in [1., 1.5, 2.] {
599+
for point in [
600+
(-1920, -200),
601+
(
602+
(screen.x + width - 1.) as i32,
603+
(screen.y + height - 1.) as i32,
604+
),
605+
] {
606+
let frame = Menu::new(kind, 250).frame(point, screen, units);
607+
assert!(frame.tiles.iter().any(|tile| tile.text == "Close menu"));
608+
for rect in frame
609+
.tiles
610+
.iter()
611+
.map(|tile| tile.rect)
612+
.chain(frame.label.iter().map(|label| label.rect))
613+
{
614+
assert!(rect.width > 0. && rect.height > 0.);
615+
assert!(rect.x >= screen.x && rect.y >= screen.y);
616+
assert!(rect.x + rect.width <= screen.x + screen.width + 0.001);
617+
assert!(rect.y + rect.height <= screen.y + screen.height + 0.001);
618+
}
619+
}
620+
}
621+
}
622+
}
623+
}
624+
#[test]
625+
fn every_submenu_is_reachable_and_fits_three_columns_and_four_rows() {
534626
let mut pending = vec![Kind::Actions];
535627
let mut visited = vec![];
536628
let mut commands = vec![];
@@ -540,7 +632,7 @@ mod tests {
540632
}
541633
visited.push(kind);
542634
let rows = kind.rows();
543-
assert!(rows.len() <= 3);
635+
assert!(rows.len() <= 4);
544636
assert!(rows.iter().all(|r| r.len() <= 3));
545637
if kind != Kind::Actions {
546638
assert!(rows.iter().flatten().any(|i| *i == Item::Back));

0 commit comments

Comments
 (0)