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: 3 additions & 3 deletions docs/point-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

Point scan ports the Android line-only and grid-then-line techniques to Switchify PC. The reference source is `switchifyapp/switchify-android` commit `856720d8747e2f3d1724a572bf754ffae05df299`, especially `PointScanLineManager`, `PointScanBlockManager`, and `ContinuousLineSpeedUtils`.

Open **Settings → Switches** to add named keyboard switches and assign normal and hold actions. Then enable scanning in **Settings → Scanning**. Fresh installs have no assignments; old point-scan keys migrate once. See [switch assignments](switches.md). Scanning stays off at startup.
Open **Settings → Switches** to add named keyboard switches and assign normal and hold actions. There is no on/off control: scanning is armed whenever the saved switches cover the current mode (Select for automatic scanning; Select, Next and Previous for manual) and the environment allows it. The runtime re-arms after a save, after key learning, after Escape, when an Android session ends, and retries a failed key reservation every two seconds. Fresh installs have no assignments; old point-scan keys migrate once. See [switch assignments](switches.md). Scanning arms at startup, so assigned keys are reserved from launch.

Focus the intended application and press Select to start. Line mode chooses X, then Y, and clicks once. Grid mode chooses a row, then a cell, before the same line sequence. Selecting happens on switch release; holding any switch freezes the position and repeat keydowns do not select again. The scan resets after a click and waits for the next Select. Movement wraps at the selected region's edges. Automatic movement stops after three full passes of the current phase without a selection; the scan resets and waits for the next Select. Manual steps never trigger this limit, and each Select starts a fresh count for the next phase. Android's five speeds are 45, 75, 120, 180, and 270 logical units per second, with delayed ticks capped at 250 ms.

The scan uses the monitor under the pointer when it starts. Windows uses native physical coordinates and display scaling; macOS uses Core Graphics display units and converts overlay rectangles to AppKit coordinates. A monitor geometry change cancels scanning. Native overlay strips are topmost, click-through, and nonactivating. The pointer moves only for the final click.

This first port uses switches attached to the computer. Android must be disconnected before enabling it, and an Android connection cancels local scanning. Existing authenticated Bluetooth commands and switch-forwarding profiles are unchanged. Input permission is still required on macOS. Point settings use a separate `point-scan.json` in the existing Tauri application configuration directory, so old settings and pairing schemas remain unchanged.
This first port uses switches attached to the computer. An Android connection pauses local scanning, which resumes when the connection ends. Existing authenticated Bluetooth commands and switch-forwarding profiles are unchanged. Input permission is still required on macOS. Point settings use a separate `point-scan.json` in the existing Tauri application configuration directory, so old settings and pairing schemas remain unchanged.

Changes save automatically, including when you leave Settings. Enabling waits for pending saves; a failed save keeps your edits and offers Retry save. Configuration is locked while scanning is enabled. Navigating away does not stop scanning.
Changes save automatically, including when you leave Settings, and apply straight away: saving pauses scanning and the runtime re-arms it with the new settings. A failed save keeps your edits and offers Retry save. Navigating away does not stop scanning.

The engine is independent of OS input. Activation uses the existing `InputInjector` adapter. Automated tests use a fake adapter and never move the real pointer. Switchify's local `switch_input` adapter supplies press/release events on both platforms. See `switches.md` for Windows capture limitations. Only the main window can configure scanning through IPC.

Expand Down
8 changes: 4 additions & 4 deletions docs/switches.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# Switch assignments

In Settings → Switches, each switch is a row showing its name, key and actions; Edit expands it. Add switch starts learning immediately: press and release the physical key, then enter a name and choose actions, then save. Learning can also be restarted from Change key. Assign Select, Next, Previous, Reverse direction, Stop scanning or Pause / resume. Each key belongs to one switch; several switches can run the same action. Existing assignments save automatically. Disable scanning before editing. While learning, a modal dialog holds keyboard focus and swallows key events so a switch press cannot scroll Settings or activate a button; Cancel capture is mouse-only and Escape cancels through the backend. Learning ends when Settings loses focus, the panel closes, or Escape is pressed.
In Settings → Switches, each switch is a row showing its name, key and actions; Edit expands it. Add switch starts learning immediately: press and release the physical key, then enter a name and choose actions, then save. Learning can also be restarted from Change key. Assign Select, Next, Previous, Reverse direction, Stop scanning or Pause / resume. Each key belongs to one switch; several switches can run the same action. Existing assignments save automatically and apply straight away; saving or learning pauses scanning until it finishes. While learning, a modal dialog holds keyboard focus and swallows key events so a switch press cannot scroll Settings or activate a button; Cancel capture is mouse-only and Escape cancels through the backend. Learning ends when Settings loses focus, the panel closes, or Escape is pressed.

The normal action runs on release. Hold actions are offered in their configured order. The interval defaults to 1 second and accepts 250–5000 milliseconds; five presets are shown and the rest sit behind an exact-interval select. The editor shows the time at which each hold action is offered. Each interval offers the next action in a nonactivating desktop prompt; the last remains selected. Release executes only the offered action. Movement freezes while any switch is held. The first pressed switch owns the gesture; overlapping presses do not activate additional actions.

Stop resets the scan but leaves switches enabled. Select starts again. Pause preserves position. Reverse changes direction without stepping. Automatic scanning requires Select; manual scanning also requires Next and Previous. Hold assignments count toward those requirements.
Stop resets the scan and leaves the keys reserved. Select starts again. Pause preserves position. Reverse changes direction without stepping. Automatic scanning requires Select; manual scanning also requires Next and Previous. Hold assignments count toward those requirements.

Escape disables capture immediately. The emergency hold timeout is at least 4 seconds and extends to (longest hold list + 2) × interval when hold actions exist. Settings shows the resulting duration. Heartbeat loss, capture failure, overflow, Android connection and shutdown cancel gestures without executing release actions.
Escape and the emergency hold reset the scan and release the keys; the runtime re-arms them on its next tick. The emergency hold timeout is at least 4 seconds and extends to (longest hold list + 2) × interval when hold actions exist. Settings shows the resulting duration. Heartbeat loss, capture failure, overflow, Android connection and shutdown cancel gestures without executing release actions.

## Storage and integration

The separate switch-settings.json uses schema version 1. Existing point-scan keys migrate once into four named assignments with empty hold lists. Fresh installs persist an empty list. Malformed or unsupported settings are reported without overwriting them. Saves use atomic replacement. Scanning stays off at startup.
The separate switch-settings.json uses schema version 1. Existing point-scan keys migrate once into four named assignments with empty hold lists. Fresh installs persist an empty list. Malformed or unsupported settings are reported without overwriting them. Saves use atomic replacement. Scanning arms at startup whenever the saved switches allow.

Existing point-scan commands and event fields remain compatible for geometry and automatic mode. Legacy key fields remain readable; changing them is rejected with a direction to Settings → Switches. New settings and learning commands are restricted to the main window capability and check its label. On Windows the learning command checks that the main window is the foreground window rather than tao's focus flag, which turns false once keyboard focus moves into the WebView2 child.

Expand Down
24 changes: 13 additions & 11 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ pub(crate) fn sync_tray_state(app: &AppHandle, state: &AppState) {
}

fn finish_app_exit(app: &AppHandle) {
scanning_runtime::halt(app);
app.state::<switch_runtime::Controller>().shutdown();
scanning_runtime::cancel(app);
app.state::<dwell::DwellController>().cancel(app);
let model = app.state::<AppModel>();
let _ = platform_disconnect_all(app, &model.shared);
Expand Down Expand Up @@ -1263,9 +1263,8 @@ fn save_switches(
settings: switches::Settings,
) -> Result<switch_runtime::View, String> {
require_main(&window)?;
if app.state::<point_scan_runtime::Controller>().view().enabled {
return Err("Disable scanning before editing switches.".into());
}
// Sync commands run on the main thread, which pausing the overlay needs.
point_scan_runtime::pause(&app);
app.state::<switch_runtime::Controller>()
.save(&app, settings)
}
Expand All @@ -1275,13 +1274,11 @@ fn begin_switch_capture(
app: AppHandle,
) -> Result<switch_runtime::View, String> {
require_main(&window)?;
if app.state::<point_scan_runtime::Controller>().view().enabled {
return Err("Disable scanning before learning a switch.".into());
}
if !main_window_focused(&window)? {
return Err("Focus Switchify PC before learning a switch.".into());
}
point_scan_prepare(&app)?;
point_scan_runtime::pause(&app);
app.state::<switch_runtime::Controller>()
.begin_capture(&app)
}
Expand All @@ -1307,26 +1304,31 @@ fn get_point_scan(
async fn configure_point_scan(
app: AppHandle,
config: point_scan::Config,
enabled: bool,
) -> Result<point_scan_runtime::View, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
let handle = app.clone();
app.run_on_main_thread(move || {
let _ = tx.send(point_scan_runtime::configure(&handle, config, enabled));
let _ = tx.send(point_scan_runtime::configure(&handle, config));
})
.map_err(|e| e.to_string())?;
rx.await
.map_err(|_| "Point scan configuration was cancelled.".to_string())?
}

fn point_scan_prepare(app: &AppHandle) -> Result<(), String> {
/// Pure environment check, safe to call every tick while scanning is off.
fn point_scan_ready(app: &AppHandle) -> Result<(), String> {
let state = app.state::<AppModel>().snapshot();
if state.bluetooth == state::BluetoothState::Connected {
return Err("Disconnect Android before using local point scan.".into());
return Err("Local scanning pauses while Android is connected.".into());
}
if state.accessibility != state::AccessibilityState::Granted {
return Err("Grant input access before using point scan.".into());
}
Ok(())
}

fn point_scan_prepare(app: &AppHandle) -> Result<(), String> {
point_scan_ready(app)?;
app.state::<dwell::DwellController>().cancel(app);
platform_stop_mouse_repeat(app);
Ok(())
Expand Down
12 changes: 9 additions & 3 deletions src-tauri/src/point_scan_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ use tauri::{AppHandle, Manager};
pub struct PointScan;
pub type Controller = scanning_runtime::Controller<PointScan>;
pub type View = scanning_runtime::View<Config, Phase>;
pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result<View, String> {
pub fn configure(app: &AppHandle, config: Config) -> Result<View, String> {
let previous = app.state::<Controller>().view().config;
if config.switches().keys() != previous.switches().keys() {
return Err("Edit key assignments in Settings → Switches.".into());
}
scanning_runtime::configure::<PointScan>(app, config, enabled)
scanning_runtime::configure::<PointScan>(app, config)
}
pub fn pause(app: &AppHandle) {
scanning_runtime::pause::<PointScan>(app);
}
pub fn install(app: &AppHandle) {
scanning_runtime::install::<PointScan>(app);
Expand All @@ -37,6 +40,9 @@ impl Adapter for PointScan {
fn validate_environment(app: &AppHandle, display: Option<&Display>) -> Result<(), String> {
validate_display(app, display)
}
fn ready(app: &AppHandle) -> Result<(), String> {
crate::point_scan_ready(app)
}
fn prepare(app: &AppHandle) -> Result<(), String> {
crate::point_scan_prepare(app)
}
Expand Down Expand Up @@ -70,7 +76,7 @@ fn validate_display(app: &AppHandle, display: Option<&Display>) -> Result<(), St
if let Some(expected) = display {
let (_, displays) = display_navigation::displays(app).map_err(|e| e.message)?;
if !displays.contains(expected) {
return Err("Display geometry changed. Enable point scan again.".into());
return Err("Display geometry changed. Scanning restarts.".into());
}
}
Ok(())
Expand Down
Loading