diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 3e45060..22402d9 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -474,7 +474,13 @@ pub(crate) fn render_marker(frame: &Frame, scale: f64) -> Pixmap { pixmap } -fn draw_dwell_progress(pixmap: &mut Pixmap, center: f32, unit: f32, color: [u8; 3], permille: u16) { +pub(crate) fn draw_dwell_progress( + pixmap: &mut Pixmap, + center: f32, + unit: f32, + color: [u8; 3], + permille: u16, +) { let radius = unit * 0.29; let width = (unit * 0.045).max(3.0); let segments = 64usize; diff --git a/src-tauri/src/overlay_windows.rs b/src-tauri/src/overlay_windows.rs index 603f77d..891d65f 100644 --- a/src-tauri/src/overlay_windows.rs +++ b/src-tauri/src/overlay_windows.rs @@ -229,7 +229,7 @@ pub(crate) fn present_solid( present_rgba(window, x, y, width.max(1), height.max(1), &rgba) } -fn present_rgba( +pub(crate) fn present_rgba( window: HWND, x: i32, y: i32, diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 9d04ed0..88b4b1c 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -21,6 +21,8 @@ pub struct Config { pub speed: usize, pub grid_size: usize, pub block_interval_ms: u64, + pub auto_select_enabled: bool, + pub auto_select_delay_ms: u64, pub select_key: String, pub next_key: String, pub back_key: String, @@ -35,6 +37,8 @@ impl Default for Config { speed: 2, grid_size: 4, block_interval_ms: 1000, + auto_select_enabled: false, + auto_select_delay_ms: 1000, select_key: "Space".into(), next_key: "Enter".into(), back_key: "Backspace".into(), @@ -59,6 +63,8 @@ impl Config { speed: self.speed, grid_size: self.grid_size, block_interval_ms: self.block_interval_ms, + auto_select_enabled: self.auto_select_enabled, + auto_select_delay_ms: self.auto_select_delay_ms, } } pub fn validate(&self) -> Result<(), String> { @@ -73,12 +79,16 @@ pub struct PointSettings { pub speed: usize, pub grid_size: usize, pub block_interval_ms: u64, + pub auto_select_enabled: bool, + pub auto_select_delay_ms: u64, } impl PointSettings { fn validate(&self) -> Result<(), String> { if self.speed > 4 || !(2..=10).contains(&self.grid_size) || !(250..=5000).contains(&self.block_interval_ms) + || !(100..=100_000).contains(&self.auto_select_delay_ms) + || !self.auto_select_delay_ms.is_multiple_of(100) { return Err("Point scan speed, grid size, or interval is invalid.".into()); } @@ -412,6 +422,7 @@ impl Technique for Engine { grid, strips, tiles: vec![], + countdown: None, label: (self.phase == Phase::RowEscape).then(|| { let scale = self.units_per_logical_pixel; let width = (360.0 * scale).min(self.screen.width); @@ -471,6 +482,28 @@ mod tests { }) } #[test] + fn auto_select_delays_are_bounded_and_round_trip() { + for delay in [100, 500, 1000, 100_000] { + let config = Config { + auto_select_enabled: true, + auto_select_delay_ms: delay, + ..Config::default() + }; + config.validate().unwrap(); + let restored: Config = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert_eq!(restored, config); + } + for delay in [0, 99, 101, 100_100, u64::MAX] { + assert!(Config { + auto_select_delay_ms: delay, + ..Config::default() + } + .validate() + .is_err()); + } + } + #[test] fn grid_highlights_fill_only_the_current_target_then_clear_for_lines() { for scale in [1.0, 2.0] { let config = Config { @@ -835,11 +868,15 @@ mod tests { assert!(e.technique.x > -1000.0); } #[test] - fn existing_flat_settings_round_trip_without_schema_changes() { + fn existing_flat_settings_gain_safe_auto_select_defaults() { let mut json = serde_json::json!({"mode":"grid","automatic":false,"speed":4,"gridSize":7,"blockIntervalMs":1500,"selectKey":"F1","nextKey":"F2","backKey":"F3","pauseKey":"F4"}); let config: Config = serde_json::from_value(json.clone()).unwrap(); config.validate().unwrap(); assert_eq!(config.scanner_color, crate::scanning::ScannerColor::Blue); + assert!(!config.auto_select_enabled); + assert_eq!(config.auto_select_delay_ms, 1000); + json["autoSelectEnabled"] = serde_json::json!(false); + json["autoSelectDelayMs"] = serde_json::json!(1000); json["scannerColor"] = serde_json::json!("blue"); assert_eq!(serde_json::to_value(&config).unwrap(), json); assert!(!config.switches().automatic); diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 6639259..9e4f557 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -2,7 +2,7 @@ use crate::{ point_scan::{Engine, PointSettings}, scan_menu::{outline, Item, Kind, Menu}, - scanning::{Action, Frame, FrameLabel, Rect, Technique}, + scanning::{Action, Frame, FrameLabel, Rect, Technique, UpdateContext}, }; use serde::Serialize; pub type Point = (i32, i32); @@ -28,7 +28,6 @@ pub enum Request { DragMove(Point), DragEnd(Point), } -/// Future auto-selection policy can return this same request after its countdown. pub fn default_click(point: Point) -> Request { Request::Click { point, @@ -55,6 +54,7 @@ impl Default for Phase { pub enum WorkflowPhase { Menu, MenuSuspended, + AutoSelecting, DragDestination, DragConfirmation, Executing, @@ -62,6 +62,7 @@ pub enum WorkflowPhase { #[derive(PartialEq)] enum Stage { Idle, + Countdown, Point, Menu, Destination, @@ -94,6 +95,11 @@ impl Workflow { }) } pub fn apply_config(&mut self, config: PointSettings, restart: bool) { + if self.stage == Stage::Countdown { + self.pending = None; + self.elapsed = 0; + self.open(Kind::Actions); + } self.point.config = config; self.menu.set_period(self.point.config.block_interval_ms); for menu in &mut self.parent_menu { @@ -233,6 +239,9 @@ impl Technique for Workflow { fn complete_on_selection(&self) -> bool { false } + fn auto_selecting(&self) -> bool { + self.stage == Stage::Countdown + } fn pausable(&self) -> bool { self.stage != Stage::Executing } @@ -248,7 +257,12 @@ impl Technique for Workflow { selection_policy(point, self.point.config.block_interval_ms); self.source = target; self.menu = menu; - self.stage = Stage::Menu; + self.elapsed = 0; + self.stage = if self.point.config.auto_select_enabled { + Stage::Countdown + } else { + Stage::Menu + }; } } } @@ -264,6 +278,10 @@ impl Technique for Workflow { return self.selected(item); } } + Stage::Countdown => { + self.elapsed = 0; + self.open(Kind::Actions); + } Stage::Idle | Stage::Executing => {} } None @@ -285,8 +303,19 @@ impl Technique for Workflow { _ => {} } } - fn update(&mut self, ms: u64, advancing: bool) { - if self.stage == Stage::Executing { + fn update(&mut self, ms: u64, context: UpdateContext) { + if self.stage == Stage::Countdown { + if !context.paused && !context.switch_held { + self.elapsed = self + .elapsed + .saturating_add(ms) + .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; + } + } + } else if self.stage == Stage::Executing { self.elapsed = (self.elapsed + ms).min(300); let t = self.elapsed as f64 / 300.0; let p = ( @@ -301,7 +330,7 @@ impl Technique for Workflow { } else { Request::DragMove(p) }); - } else if advancing { + } else if context.movement_enabled { self.advance(ms); } } @@ -311,6 +340,7 @@ impl Technique for Workflow { fn phase(&self) -> Phase { match self.stage { Stage::Idle => Phase::default(), + Stage::Countdown => Phase::Workflow(WorkflowPhase::AutoSelecting), Stage::Point => Phase::Point(self.point.phase()), Stage::Destination => Phase::Workflow(WorkflowPhase::DragDestination), Stage::Executing => Phase::Workflow(WorkflowPhase::Executing), @@ -325,6 +355,32 @@ impl Technique for Workflow { } fn frame(&self) -> Frame { let mut frame = match self.stage { + Stage::Countdown => { + let scale = self.point.units_per_logical_pixel; + let screen = self.point.screen; + let width = (480.0 * scale).min(screen.width); + Frame { + countdown: Some(crate::scanning::Countdown { + point: self.source, + scale, + permille: (self.elapsed * 1000 / self.point.config.auto_select_delay_ms) + as u16, + color: self.point.config.scanner_color, + }), + label: Some(FrameLabel { + text: "Press a switch for the action menu.".into(), + rect: Rect { + x: screen.x + (screen.width - width) / 2.0, + y: screen.y + 20.0 * scale, + width, + height: (64.0 * scale).min(screen.height), + }, + scale, + hud: Some(crate::scanning::HudPresentation { screen, scale }), + }), + ..Frame::default() + } + } Stage::Point | Stage::Destination => self.point.frame(), Stage::Menu => self.menu.frame( if self.menu.kind == Kind::ConfirmDrag { @@ -426,6 +482,158 @@ mod tests { } s.action(Action::Select) } + fn auto_session( + mode: crate::point_scan::Mode, + automatic: bool, + ) -> crate::scanning::Session { + let config = crate::point_scan::Config { + mode, + automatic, + auto_select_enabled: true, + ..Default::default() + }; + let workflow = Workflow::new( + config.point(), + Rect { + x: -1000.0, + y: -100.0, + width: 800.0, + height: 600.0, + }, + 1.0, + ) + .unwrap(); + let mut session = crate::scanning::Session::new(workflow, automatic); + let presses = if mode == crate::point_scan::Mode::Grid { + 5 + } else { + 3 + }; + for _ in 0..presses { + assert_eq!(session.action(Action::Select), None); + } + assert!(session.technique.auto_selecting()); + session + } + fn advance_auto(session: &mut crate::scanning::Session, duration: u64, held: bool) { + for _ in 0..duration / 100 { + session.tick(100, held); + } + session.tick(duration % 100, held); + } + #[test] + fn auto_click_runs_once_in_every_scan_mode_and_then_waits() { + for mode in [crate::point_scan::Mode::Line, crate::point_scan::Mode::Grid] { + for automatic in [false, true] { + let mut session = auto_session(mode, automatic); + let target = session.technique.source; + advance_auto(&mut session, 999, false); + assert_eq!(session.take_selection(), None); + assert_eq!(session.frame().countdown.as_ref().unwrap().point, target); + session.tick(1, false); + assert_eq!(session.take_selection(), Some(default_click(target))); + assert!(!session.active()); + advance_auto(&mut session, 2000, false); + assert_eq!(session.take_selection(), None); + assert!(session.frame().countdown.is_none()); + } + } + } + #[test] + fn held_switch_and_pause_freeze_countdown_and_menu_consumes_selection() { + let mut session = auto_session(crate::point_scan::Mode::Line, false); + advance_auto(&mut session, 900, false); + advance_auto(&mut session, 2000, true); + assert_eq!(session.frame().countdown.unwrap().permille, 900); + session.action(Action::Pause); + advance_auto(&mut session, 2000, false); + assert_eq!(session.frame().countdown.unwrap().permille, 900); + session.action(Action::Pause); + assert_eq!(session.action(Action::Select), None); + assert_eq!( + session.technique.phase(), + Phase::Workflow(WorkflowPhase::Menu) + ); + advance_auto(&mut session, 2000, false); + assert_eq!(session.take_selection(), None); + assert!(!session.frame().tiles.is_empty()); + } + #[test] + fn pause_resumes_remaining_time_and_reset_discards_even_pending_click() { + let mut session = auto_session(crate::point_scan::Mode::Line, true); + advance_auto(&mut session, 900, false); + session.action(Action::Pause); + advance_auto(&mut session, 500, false); + session.action(Action::Pause); + advance_auto(&mut session, 99, false); + assert_eq!(session.take_selection(), None); + session.tick(1, false); + session.reset(); + assert_eq!(session.take_selection(), None); + for action in [Action::Cancel, Action::Stop] { + let mut session = auto_session(crate::point_scan::Mode::Line, false); + session.action(action); + advance_auto(&mut session, 2000, false); + assert_eq!(session.take_selection(), None); + } + } + #[test] + fn config_changes_cancel_countdown_and_auto_click_errors_offer_menu_return() { + let mut session = auto_session(crate::point_scan::Mode::Line, false); + let config = session.technique.point.config.clone(); + session.technique.apply_config(config, false); + advance_auto(&mut session, 2000, false); + assert_eq!(session.take_selection(), None); + let mut session = auto_session(crate::point_scan::Mode::Line, false); + advance_auto(&mut session, 1000, false); + assert!(session.take_selection().is_some()); + session.execution_failed("Click failed".into()); + assert!(session + .frame() + .label + .unwrap() + .text + .contains("Select to return")); + assert_eq!(session.action(Action::Select), None); + assert_eq!( + session.technique.phase(), + Phase::Workflow(WorkflowPhase::Menu) + ); + assert_eq!(session.take_selection(), None); + } + #[test] + fn auto_selection_excludes_drag_destinations() { + let mut session = auto_session(crate::point_scan::Mode::Line, false); + session.action(Action::Select); + session.technique.selected(Item::Drag); + session.action(Action::Select); + session.action(Action::Select); + assert_eq!( + session.technique.phase(), + Phase::Workflow(WorkflowPhase::DragConfirmation) + ); + assert!(session.frame().countdown.is_none()); + } + #[test] + fn countdown_artwork_has_transparent_center_and_growing_colour() { + let mut countdown = crate::scanning::Countdown { + point: (-200, 50), + scale: 2.0, + permille: 100, + color: crate::scanning::ScannerColor::Green, + }; + let early = countdown.bitmap(2.0).unwrap(); + countdown.permille = 900; + let late = countdown.bitmap(2.0).unwrap(); + assert_eq!(late.pixel(128, 128).unwrap().alpha(), 0); + let alpha = + |p: &tiny_skia::Pixmap| p.pixels().iter().map(|p| p.alpha() as u64).sum::(); + assert!(alpha(&late) > alpha(&early)); + let rect = countdown.rect(); + assert_eq!(rect.x + rect.width / 2.0, -200.0); + assert_eq!(rect.y + rect.height / 2.0, 50.0); + } + #[test] fn nested_back_restores_parent_and_pause_resumes_without_selection() { let mut w = Workflow::new( diff --git a/src-tauri/src/scan_executor.rs b/src-tauri/src/scan_executor.rs index eed4b14..b0b6629 100644 --- a/src-tauri/src/scan_executor.rs +++ b/src-tauri/src/scan_executor.rs @@ -235,7 +235,11 @@ mod tests { } fn set_pointer_button(&mut self, button: MouseButton, down: bool) -> Result<(), String> { self.events.push(format!("button {button:?} {down}")); - Ok(()) + if !down && self.fail_release { + Err("release failed".into()) + } else { + Ok(()) + } } fn scroll(&mut self, dx: i32, dy: i32) -> Result<(), String> { self.events.push(format!("scroll {dx} {dy}")); @@ -262,6 +266,22 @@ mod tests { } } #[test] + fn resetting_an_executing_drag_releases_input_and_retains_failed_cleanup_for_retry() { + let mut input = DesktopInput::new(Fake::default()); + execute(&mut input, Request::DragStart((100, 200)), true).unwrap(); + execute(&mut input, Request::DragMove((150, 250)), true).unwrap(); + input.injector.fail_release = true; + assert!(input.release_all().is_err()); + assert!(input.has_active_drag()); + input.injector.fail_release = false; + input.release_all().unwrap(); + assert!(!input.has_active_drag()); + assert!(input + .injector + .events + .ends_with(&["button Left false".into()])); + } + #[test] fn editing_shortcuts_preserve_focus_and_release_keys_in_reverse_order() { let mut input = DesktopInput::new(Fake::default()); execute( diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index aa44f5d..c7e8fd8 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -102,6 +102,19 @@ mod platform { scale, ) } + pub fn countdown(&mut self, countdown: &crate::scanning::Countdown) -> Result<(), String> { + self.ensure_windows(1)?; + let rect = countdown.rect(); + let pixels = countdown.bitmap(1.0)?; + crate::overlay::platform::present_rgba( + self.windows[0], + rect.x.round() as i32, + rect.y.round() as i32, + pixels.width() as i32, + pixels.height() as i32, + pixels.data(), + ) + } pub fn tile(&mut self, tile: &crate::scanning::FrameTile) -> Result<(), String> { self.ensure_windows(1)?; crate::modifier_overlay::windows_backend::present_scan_tile(self.windows[0], tile) @@ -133,6 +146,7 @@ mod platform { use objc2_foundation::{NSPoint, NSRect, NSSize}; pub struct Host { panels: Vec>, + countdown_view: Option>, last_rects: Vec, last_title: Option<(String, Rect, f64, Option)>, title: Option<( @@ -144,6 +158,7 @@ mod platform { pub fn new() -> Result { Ok(Self { panels: vec![], + countdown_view: None, last_rects: vec![], title: None, last_title: None, @@ -284,6 +299,35 @@ mod platform { pub fn prompt(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> { self.text_panel(text, rect, scale, None) } + pub fn countdown(&mut self, countdown: &crate::scanning::Countdown) -> Result<(), String> { + use objc2_app_kit::NSImageView; + let mtm = MainThreadMarker::new().ok_or("Countdown requires the main thread.")?; + let rect = countdown.rect(); + self.render(&[crate::scanning::PaintedRect { + rect, + color: [0, 0, 0], + opacity: 0, + role: crate::scanning::VisualRole::Accent, + }])?; + let ratio = self.panels[0].backingScaleFactor(); + let pixels = countdown.bitmap(ratio)?; + let image = crate::overlay::platform::image_from_rgba( + pixels.data(), + pixels.width() as usize, + pixels.height() as usize, + rect.width, + )?; + let bounds = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(rect.width, rect.height)); + if self.countdown_view.is_none() { + let view = NSImageView::initWithFrame(NSImageView::alloc(mtm), bounds); + self.panels[0].setContentView(Some(&view)); + self.countdown_view = Some(view); + } + let view = self.countdown_view.as_ref().unwrap(); + view.setFrame(bounds); + view.setImage(Some(&image)); + Ok(()) + } pub fn tile(&mut self, tile: &crate::scanning::FrameTile) -> Result<(), String> { use objc2_app_kit::{NSFont, NSImageView, NSTextAlignment, NSTextField, NSView}; use objc2_foundation::NSString; @@ -344,6 +388,9 @@ mod platform { pub fn prompt(&mut self, _: &str, _: Rect, _: f64) -> Result<(), String> { Err("Scanning is unavailable.".into()) } + pub fn countdown(&mut self, _: &crate::scanning::Countdown) -> Result<(), String> { + Err("Scanning is unavailable.".into()) + } pub fn tile(&mut self, _: &crate::scanning::FrameTile) -> Result<(), String> { Err("Scanning is unavailable.".into()) } diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index f9559c2..948d262 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -155,6 +155,7 @@ pub struct Frame { pub strips: Vec, pub tiles: Vec, pub label: Option, + pub countdown: Option, } impl Frame { pub fn rectangles(&self) -> Vec { @@ -203,6 +204,46 @@ impl Frame { self.label.as_ref().filter(|_| !prompt_visible) } } +#[derive(Debug, Clone, PartialEq)] +pub struct Countdown { + pub point: (i32, i32), + pub scale: f64, + pub permille: u16, + pub color: ScannerColor, +} + +impl Countdown { + pub fn rect(&self) -> Rect { + let size = 64.0 * self.scale; + Rect { + x: f64::from(self.point.0) - size / 2.0, + y: f64::from(self.point.1) - size / 2.0, + width: size, + height: size, + } + } + pub fn bitmap(&self, pixel_ratio: f64) -> Result { + let size = (64.0 * self.scale * pixel_ratio).ceil().clamp(1.0, 1024.0) as u32; + let mut bitmap = + tiny_skia::Pixmap::new(size, size).ok_or("Cannot draw the auto-select countdown.")?; + crate::overlay::draw_dwell_progress( + &mut bitmap, + size as f32 / 2.0, + size as f32, + self.color.rgb(), + self.permille, + ); + Ok(bitmap) + } +} + +#[derive(Clone, Copy)] +pub struct UpdateContext { + pub movement_enabled: bool, + pub paused: bool, + pub switch_held: bool, +} + #[derive(Debug, Clone, PartialEq)] pub struct FrameTile { pub color: ScannerColor, @@ -233,6 +274,9 @@ pub const MAX_SCAN_CYCLES: usize = 3; pub trait Technique { type Selection; type Phase: Clone + Default + PartialEq + Serialize; + fn auto_selecting(&self) -> bool { + false + } fn execution_failed(&mut self, _message: String) {} fn start(&mut self); fn advance(&mut self, elapsed_ms: u64); @@ -251,8 +295,8 @@ pub trait Technique { fn finished(&self) -> bool { false } - fn update(&mut self, elapsed_ms: u64, advancing: bool) { - if advancing { + fn update(&mut self, elapsed_ms: u64, context: UpdateContext) { + if context.movement_enabled { self.advance(elapsed_ms); } } @@ -308,6 +352,9 @@ impl Session { } return None; } + if self.technique.auto_selecting() { + self.paused = false; + } let selection = self.technique.handle(action); if !self.technique.pausable() { self.paused = false; @@ -324,7 +371,11 @@ impl Session { if self.active && elapsed_ms > 0 { self.technique.update( elapsed_ms.min(MAX_ELAPSED_MS), - self.automatic && !self.paused && !select_held, + UpdateContext { + movement_enabled: self.automatic && !self.paused && !select_held, + paused: self.paused, + switch_held: select_held, + }, ); if self.technique.exhausted() { self.reset(); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 6857d6b..52e7062 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -43,7 +43,7 @@ use std::{ }; use tauri::{AppHandle, Emitter, Manager}; -thread_local! {static HOST:RefCell>=const{RefCell::new(None)}; static PROMPT:RefCell>=const{RefCell::new(None)}; static LABEL:RefCell>=const{RefCell::new(None)}; static TILES:RefCell<(Vec,Vec)>=const{RefCell::new((vec![],vec![]))};} +thread_local! {static COUNTDOWN: RefCell<(Option, Option)> = const { RefCell::new((None, None)) }; static HOST:RefCell>=const{RefCell::new(None)}; static PROMPT:RefCell>=const{RefCell::new(None)}; static LABEL:RefCell>=const{RefCell::new(None)}; static TILES:RefCell<(Vec,Vec)>=const{RefCell::new((vec![],vec![]))};} /// Scanning has no on/off switch. It is armed whenever the saved switches can /// drive the current mode and the environment allows it, and the tick loop /// re-arms it after anything that stopped it: a save, key learning, Escape, an @@ -422,6 +422,24 @@ fn dispatch( } Ok(()) } +fn render_countdown(countdown: Option<&crate::scanning::Countdown>) -> Result<(), String> { + COUNTDOWN.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.1.as_ref() == countdown { + return Ok(()); + } + if let Some(countdown) = countdown { + if slot.0.is_none() { + slot.0 = Some(Host::new()?); + } + slot.0.as_mut().unwrap().countdown(countdown)?; + } else if let Some(host) = slot.0.as_mut() { + host.hide(); + } + slot.1 = countdown.cloned(); + Ok(()) + }) +} fn render_tiles(tiles: &[crate::scanning::FrameTile]) -> Result<(), String> { TILES.with(|slot| { let mut slot = slot.borrow_mut(); @@ -460,6 +478,7 @@ fn render( } })?; render_tiles(&frame.tiles)?; + render_countdown(frame.countdown.as_ref())?; render_label(frame.label_for_prompt(prompt.is_some()), &frame.tiles) } fn tick(app: &AppHandle) { @@ -505,19 +524,24 @@ fn tick(app: &AppHandle) { publish::(app); } for edge in edges { + if matches!(edge, crate::remote_scan::Edge::Reset) { + reset_scanner::(app, "Remote switches changed. Scan reset."); + return; + } let action = { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); match edge { - crate::remote_scan::Edge::Reset => { - d.pressed.cancel(); - d.remote_hold_started = None; - None - } + crate::remote_scan::Edge::Reset => unreachable!(), crate::remote_scan::Edge::Down(id) => { if !d.pressed.held() { d.remote_hold_started = Some(now_ms); } - d.pressed.pressed(&id.to_string(), now_ms, &settings); + let countdown = d + .engine + .as_ref() + .is_some_and(|e| e.technique.auto_selecting()); + d.pressed + .pressed_for_scan(&id.to_string(), now_ms, &settings, countdown); None } crate::remote_scan::Edge::Up(id) => { @@ -575,7 +599,12 @@ fn tick(app: &AppHandle) { } if action == crate::switch_input::Action::Pressed { let settings = d.switches.clone(); - d.pressed.pressed(&switch_id, monotonic_ms, &settings); + let countdown = d + .engine + .as_ref() + .is_some_and(|e| e.technique.auto_selecting()); + d.pressed + .pressed_for_scan(&switch_id, monotonic_ms, &settings, countdown); None } else { d.pressed.released(&switch_id, monotonic_ms) @@ -685,6 +714,7 @@ fn render_label( }) } fn hide_prompt() { + let _ = render_countdown(None); let _ = render_label(None, &[]); hide_hold_prompt(); } diff --git a/src-tauri/src/switch_gestures.rs b/src-tauri/src/switch_gestures.rs index 64523ab..166a445 100644 --- a/src-tauri/src/switch_gestures.rs +++ b/src-tauri/src/switch_gestures.rs @@ -22,11 +22,25 @@ pub struct Gestures { interval_ms: u64, } impl Gestures { + #[cfg(test)] pub fn pressed(&mut self, id: &str, now: u64, settings: &Settings) { + self.pressed_for_scan(id, now, settings, false); + } + pub fn pressed_for_scan(&mut self, id: &str, now: u64, settings: &Settings, countdown: bool) { if !self.held.insert(id.into()) || self.held.len() != 1 { return; } if let Some(binding) = settings.bindings.iter().find(|b| b.id == id) { + let mut binding = binding.clone(); + if countdown + && !matches!( + binding.press_action, + Action::Stop | Action::Pause | Action::Cancel + ) + { + binding.press_action = Action::Select; + binding.hold_actions.clear(); + } self.interval_ms = settings.hold_interval_ms; self.press = Some(Press { binding: binding.clone(), @@ -93,6 +107,40 @@ mod tests { } } #[test] + fn countdown_press_overrides_normal_actions_and_holds_once() { + for id in ["one", "two"] { + for duration in [10, 999, 2500] { + let mut gestures = Gestures::default(); + gestures.pressed_for_scan(id, 0, &settings(), true); + assert!(gestures.held()); + assert!(gestures.prompt(duration).is_none()); + assert_eq!(gestures.released(id, duration), Some(Action::Select)); + assert_eq!(gestures.released(id, duration), None); + } + } + } + #[test] + fn countdown_preserves_safety_switches_and_cancellation() { + for action in [Action::Stop, Action::Pause, Action::Cancel] { + let mut settings = settings(); + settings.bindings[0].press_action = action; + let mut gestures = Gestures::default(); + gestures.pressed_for_scan("one", 0, &settings, true); + assert_eq!(gestures.released("one", 10), Some(action)); + gestures.pressed_for_scan("one", 20, &settings, true); + gestures.cancel(); + assert_eq!(gestures.released("one", 30), None); + } + } + #[test] + fn overlapping_countdown_switches_do_not_select_a_menu_tile() { + let mut gestures = Gestures::default(); + gestures.pressed_for_scan("one", 0, &settings(), true); + gestures.pressed_for_scan("two", 10, &settings(), true); + assert_eq!(gestures.released("one", 20), Some(Action::Select)); + assert_eq!(gestures.released("two", 30), None); + } + #[test] fn boundary_and_last_action_match_android() { for (duration, expected) in [ (999, Action::Select), diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 7058764..31db967 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -189,6 +189,7 @@ it("explains the selectable row escape phase", async () => { }); it.each([ + ["autoSelecting", "Waiting to click. Press a switch for the action menu."], ["menu", "Choose an action at the selected point."], ["menuSuspended", "Select to resume the action menu."], ["dragDestination", "Choose drag destination."], @@ -223,3 +224,19 @@ it("retains a failed colour selection for retry", async () => { config: { ...defaultPointScanConfig, scannerColor: "white" }, })); }); + +it("saves auto selection separately from scan movement and validates its delay", async () => { + render(); + await screen.findByText(initial.message); + expect(screen.queryByRole("spinbutton", { name: "Auto select delay (seconds)" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("checkbox", { name: "Auto select" })); + const delay = screen.getByRole("spinbutton", { name: "Auto select delay (seconds)" }); + expect(delay).toHaveValue(1); + fireEvent.change(delay, { target: { value: "0.5" } }); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: { ...defaultPointScanConfig, autoSelectEnabled: true, autoSelectDelayMs: 500 }, + })); + const calls = mocks.invoke.mock.calls.length; + fireEvent.change(delay, { target: { value: "0.05" } }); + expect(mocks.invoke).toHaveBeenCalledTimes(calls); +}); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index c8fdb07..77dc104 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -9,6 +9,8 @@ export type PointScanConfig = { speed: number; gridSize: number; blockIntervalMs: number; + autoSelectEnabled: boolean; + autoSelectDelayMs: number; selectKey: string; nextKey: string; backKey: string; @@ -18,7 +20,7 @@ export type PointScanState = { remote?: boolean; config: PointScanConfig; enabled: boolean; - phase: "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing"; + phase: "autoSelecting" | "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing"; paused: boolean; message: string; supported: boolean; @@ -30,6 +32,8 @@ export const defaultPointScanConfig: PointScanConfig = { speed: 2, gridSize: 4, blockIntervalMs: 1000, + autoSelectEnabled: false, + autoSelectDelayMs: 1000, selectKey: "Space", nextKey: "Enter", backKey: "Backspace", diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 2adba04..dca74eb 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -8,6 +8,7 @@ import { SettingGroup, Toggle, OptionGroup, secondsOptions } from "./controls"; const phases = { idle: "Ready to begin", + autoSelecting: "Waiting to click. Press a switch for the action menu", menu: "Choose an action at the selected point", menuSuspended: "Select to resume the action menu", dragDestination: "Choose drag destination", @@ -38,7 +39,7 @@ export function ScanningSection({

Focus the application you want to use, then press Select to scan the display under the pointer. Choose the X position, then the Y position - to open the action menu. Choose a click, scroll, or drag action using your switches. + to choose a point. Use the action menu for clicks, scrolling and dragging, or enable Auto select to click after a delay.

@@ -114,6 +115,15 @@ export function ScanningSection({ + + update("autoSelectEnabled", value)} /> + {config.autoSelectEnabled && } + +

Scanner colour