Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/point-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
40 changes: 38 additions & 2 deletions src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
104 changes: 98 additions & 6 deletions src-tauri/src/scan_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<Vec<Item>>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -522,15 +540,89 @@ 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
}
}

#[cfg(test)]
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![];
Expand All @@ -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));
Expand Down