diff --git a/docs/scanner-architecture.md b/docs/scanner-architecture.md index 1b32a899..718efb56 100644 --- a/docs/scanner-architecture.md +++ b/docs/scanner-architecture.md @@ -12,7 +12,7 @@ Use `scan_items::ItemScanner` with a small typed action enum. Supply `scan_tree::Node::Group` nodes with stable group IDs and `Leaf` action identities. Group IDs must be unique among siblings; leaf identities must be unique within their group. They must identify the operation, not its current label or bounds. -The `rows` convenience constructor is for fixed row layouts: its positional row +The `configured_rows` convenience constructor is for fixed row layouts: its positional row IDs are not suitable when groups can reorder. Supply explicit groups in that case. Empty groups are removed. Explicitly identified groups retain their identity even with one child. Anonymous branches and fixed single-item rows collapse as before. @@ -56,10 +56,21 @@ including reordered content, cancellation, exactly-once completion and timing. - Native hosts render `Frame` without activating Switchify. Domain actions are executed through the existing typed workflow requests and platform adapters. -## Following stages +## Settings -Issue #801 adds validated shared settings and per-area overrides to this core. -Issue #802 adds a main-window React content adapter and foreground/modal ownership. -Those stages do not require individual screens to implement traversal or timers. -They must preserve the current input generation checks, cleanup, native focus, -point countdown cancellation and Remote behaviour. +`scan_preferences::Preferences` resolves shared defaults and optional per-area +values for point scanning, menus and the keyboard into `Resolved` options. The existing top-level automatic, interval and +colour fields remain the shared defaults for compatibility. Missing overrides +inherit them; removing an override restores inheritance. Direction, pass limit, +item pattern and highlight thickness have backward-compatible defaults. + +Use `Config::resolved(Area)` for effective settings. The workflow supplies the +active area's automatic mode to the session, so one manual area does not stop +another area's automatic movement. Switch eligibility requires navigation keys +if any area is manual. Pass limit zero means unlimited; grouped and linear item +scanning share the same navigator and execution revision. Keyboard skipping of +unavailable predictions counts automatic wraps but never manual passes. + +The existing configure API cancels a running scan and waits for Select. Native +frames carry effective colour and thickness; individual renderers do not resolve +settings. Point line/grid geometry and movement speed remain separate controls. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02f485ce..1c0cd825 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ mod scan_host; mod scan_items; mod scan_keyboard; mod scan_menu; +mod scan_preferences; mod scan_tile; mod scan_tree; mod scanning; diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 32226830..f9d8f2e9 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -1,7 +1,7 @@ //! Android point scanning, with desktop coordinates and no OS input in the engine. use crate::scan_tree::{Navigator, Node, Selection}; use crate::scanning::{ - Action, Frame, FrameLabel, Interval, Rect, SwitchSettings, Technique, MAX_SCAN_CYCLES, TICK_MS, + Action, Frame, FrameLabel, Interval, Rect, SwitchSettings, Technique, TICK_MS, }; use serde::{Deserialize, Serialize}; @@ -15,6 +15,8 @@ pub enum Mode { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { + #[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")] + pub scan_preferences: crate::scan_preferences::Preferences, pub word_prediction: bool, pub scanner_color: crate::scanning::ScannerColor, pub mode: Mode, @@ -32,6 +34,7 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { + scan_preferences: Default::default(), word_prediction: true, scanner_color: Default::default(), mode: Mode::Line, @@ -51,21 +54,43 @@ impl Default for Config { impl Config { pub fn switches(&self) -> SwitchSettings { SwitchSettings { - automatic: self.automatic, + automatic: [ + crate::scan_preferences::Area::Point, + crate::scan_preferences::Area::Menu, + crate::scan_preferences::Area::Keyboard, + ] + .into_iter() + .all(|area| self.resolved(area).automatic), select_key: self.select_key.clone(), next_key: self.next_key.clone(), back_key: self.back_key.clone(), pause_key: self.pause_key.clone(), } } + pub fn resolved( + &self, + area: crate::scan_preferences::Area, + ) -> crate::scan_preferences::Resolved { + self.scan_preferences.resolve( + area, + self.automatic, + self.block_interval_ms, + self.scanner_color, + ) + } pub fn point(&self) -> PointSettings { PointSettings { + scan: self.resolved(crate::scan_preferences::Area::Point), + menu_scan: self.resolved(crate::scan_preferences::Area::Menu), + keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard), word_prediction: self.word_prediction, - scanner_color: self.scanner_color, + scanner_color: self.resolved(crate::scan_preferences::Area::Point).color, mode: self.mode, speed: self.speed, grid_size: self.grid_size, - block_interval_ms: self.block_interval_ms, + block_interval_ms: self + .resolved(crate::scan_preferences::Area::Point) + .interval_ms, auto_select_enabled: self.auto_select_enabled, auto_select_delay_ms: self.auto_select_delay_ms, } @@ -77,6 +102,9 @@ impl Config { } #[derive(Clone)] pub struct PointSettings { + pub scan: crate::scan_preferences::Resolved, + pub menu_scan: crate::scan_preferences::Resolved, + pub keyboard_scan: crate::scan_preferences::Resolved, pub word_prediction: bool, pub scanner_color: crate::scanning::ScannerColor, pub mode: Mode, @@ -160,13 +188,30 @@ impl Engine { units_per_logical_pixel, }) } + fn initial_direction(&self) -> f64 { + if self.config.scan.direction == crate::scan_preferences::Direction::Forward { + 1.0 + } else { + -1.0 + } + } + fn reset_line_origin(&mut self) { + self.x = self.region.x; + self.y = self.region.y; + if self.initial_direction() < 0.0 { + self.x += self.region.width - 1.0; + self.y += self.region.height - 1.0; + } + } pub fn reset(&mut self) { self.phase = Phase::Idle; self.region = self.screen; - self.x = self.screen.x; - self.y = self.screen.y; + self.reset_line_origin(); self.grid.reset(); - self.direction = 1.0; + if self.initial_direction() < 0.0 { + self.grid.start_at_end(); + } + self.direction = self.initial_direction(); self.block_elapsed.reset(); self.cycles = 0; } @@ -180,18 +225,23 @@ impl Engine { match self.grid.select() { Selection::Leaf(_) => { self.region = self.cell_rect(); - self.x = self.region.x; - self.y = self.region.y; + self.reset_line_origin(); self.phase = Phase::X; } - Selection::Entered | Selection::Escaped => self.sync_grid_phase(), + Selection::Entered => { + if self.initial_direction() < 0.0 { + self.grid.start_at_end(); + } + self.sync_grid_phase(); + } + Selection::Escaped => self.sync_grid_phase(), Selection::None => {} } - self.direction = 1.0; + self.direction = self.initial_direction(); } Phase::X => { self.phase = Phase::Y; - self.direction = 1.0; + self.direction = self.initial_direction(); } Phase::Y => { let point = ( @@ -305,7 +355,7 @@ impl Engine { } /// Thin rectangles let both native hosts render without a full-screen bitmap. pub fn lines(&self) -> Vec { - let t = 2.0 * self.units_per_logical_pixel; + let t = 2.0 * self.units_per_logical_pixel * self.config.scan.thickness.scale(); let mut result = vec![]; match self.phase { Phase::Idle => return result, @@ -453,7 +503,7 @@ impl Technique for Engine { self.phase } fn exhausted(&self) -> bool { - self.cycles >= MAX_SCAN_CYCLES + self.config.scan.exhausted(self.cycles) } } @@ -892,6 +942,8 @@ mod tests { json["autoSelectDelayMs"] = serde_json::json!(1000); json["scannerColor"] = serde_json::json!("blue"); json["wordPrediction"] = serde_json::json!(true); + json["scanPreferences"] = + serde_json::to_value(crate::scan_preferences::Preferences::default()).unwrap(); assert_eq!(serde_json::to_value(&config).unwrap(), json); assert!(!config.switches().automatic); assert_eq!(config.point().grid_size, 7); @@ -932,4 +984,81 @@ mod tests { ) .is_err()); } + #[test] + fn legacy_settings_and_new_overrides_round_trip_without_changing_shared_values() { + use crate::scan_preferences::{Area, Direction, Pattern, Thickness}; + let legacy = serde_json::json!({"automatic":false,"blockIntervalMs":1500,"scannerColor":"green","speed":4}); + let mut config: Config = serde_json::from_value(legacy).unwrap(); + for area in [Area::Point, Area::Menu, Area::Keyboard] { + let resolved = config.resolved(area); + assert!(!resolved.automatic); + assert_eq!(resolved.interval_ms, 1500); + assert_eq!(resolved.color, crate::scanning::ScannerColor::Green); + assert_eq!(resolved.pass_limit, 3); + } + config.scan_preferences.keyboard.automatic = Some(true); + config.scan_preferences.keyboard.direction = Some(Direction::Reverse); + config.scan_preferences.keyboard.pattern = Some(Pattern::Linear); + config.scan_preferences.keyboard.thickness = Some(Thickness::Thick); + let stored = serde_json::to_vec(&config).unwrap(); + let restored: Config = serde_json::from_slice(&stored).unwrap(); + assert_eq!(restored, config); + assert!(!restored.automatic); + assert!(restored.resolved(Area::Keyboard).automatic); + assert!(!restored.switches().automatic); + } + + #[test] + fn obsolete_app_override_does_not_require_manual_switches() { + let config: Config = serde_json::from_value(serde_json::json!({ + "automatic": false, + "scanPreferences": { + "point": {"automatic": true}, + "menu": {"automatic": true}, + "keyboard": {"automatic": true}, + "app": {"automatic": false} + } + })) + .unwrap(); + assert!(config.switches().automatic); + let stored = serde_json::to_value(&config).unwrap(); + assert!(stored["scanPreferences"].get("app").is_none()); + let restored: Config = serde_json::from_value(stored).unwrap(); + assert_eq!(restored, config); + } + + #[test] + fn invalid_preferences_do_not_discard_existing_point_settings() { + for prefs in [ + serde_json::json!({"direction":"invalid"}), + serde_json::json!({"passLimit":999,"menu":{"intervalMs":0,"passLimit":9}}), + ] { + let config: Config = serde_json::from_value(serde_json::json!({"automatic":false,"speed":4,"blockIntervalMs":1500,"scanPreferences":prefs})).unwrap(); + assert_eq!(config.speed, 4); + assert!(!config.automatic); + let options = config.resolved(crate::scan_preferences::Area::Menu); + assert_eq!(options.pass_limit, 3); + assert_eq!(options.interval_ms, 1500); + } + } + + #[test] + fn reverse_point_scan_starts_at_the_far_edge_and_uses_its_pass_limit() { + let mut config = Config::default(); + config.scan_preferences.direction = crate::scan_preferences::Direction::Reverse; + config.scan_preferences.pass_limit = 1; + let mut session = engine(config); + session.action(Action::Select); + let before = session.technique.x; + assert_eq!( + before, + session.technique.screen.x + session.technique.screen.width - 1.0 + ); + session.tick(100, false); + assert!(session.technique.x < before); + for _ in 0..1000 { + session.tick(250, false); + } + assert!(!session.active()); + } } diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 042b5f81..b39d4c23 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -43,8 +43,8 @@ pub fn default_click(point: Point) -> Request { count: 1, } } -fn selection_policy(point: Point, period: u64) -> (Point, Menu) { - (point, Menu::new(Kind::Actions, period)) +fn selection_policy(point: Point, options: crate::scan_preferences::Resolved) -> (Point, Menu) { + (point, Menu::configured(Kind::Actions, options)) } #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] @@ -96,13 +96,17 @@ pub struct Workflow { } impl Workflow { pub fn new(config: PointSettings, screen: Rect, scale: f64) -> Result { - let period = config.block_interval_ms; + let menu_options = config.menu_scan; + let keyboard_options = config.keyboard_scan; Ok(Self { - keyboard: crate::scan_keyboard::Keyboard::new(cfg!(target_os = "macos")), + keyboard: crate::scan_keyboard::Keyboard::configured( + cfg!(target_os = "macos"), + keyboard_options, + ), keyboard_area: screen, point: Engine::new(config, screen, scale)?, stage: Stage::Idle, - menu: Menu::new(Kind::Actions, period), + menu: Menu::configured(Kind::Actions, menu_options), parent_menu: vec![], source: (0, 0), destination: (0, 0), @@ -111,6 +115,13 @@ impl Workflow { error: None, }) } + fn active_preferences(&self) -> crate::scan_preferences::Resolved { + match self.stage { + Stage::Keyboard | Stage::KeyboardOpening => self.point.config.keyboard_scan, + Stage::Menu => self.point.config.menu_scan, + _ => self.point.config.scan, + } + } pub fn apply_config(&mut self, config: PointSettings, restart: bool) { if self.stage == Stage::Countdown { self.pending = None; @@ -118,12 +129,16 @@ impl Workflow { self.open(Kind::Actions); } self.point.config = config; - self.menu.set_period(self.point.config.block_interval_ms); + self.menu + .set_period(self.point.config.menu_scan.interval_ms); for menu in &mut self.parent_menu { - menu.set_period(self.point.config.block_interval_ms); + menu.set_period(self.point.config.menu_scan.interval_ms); } if restart { - self.keyboard = crate::scan_keyboard::Keyboard::new(cfg!(target_os = "macos")); + self.keyboard = crate::scan_keyboard::Keyboard::configured( + cfg!(target_os = "macos"), + self.point.config.keyboard_scan, + ); self.parent_menu.clear(); self.stage = Stage::Point; self.point.start(); @@ -145,7 +160,7 @@ impl Workflow { self.keyboard_area = area; } fn open(&mut self, kind: Kind) { - self.menu = Menu::new(kind, self.point.config.block_interval_ms); + self.menu = Menu::configured(kind, self.point.config.menu_scan); self.stage = Stage::Menu; } fn restore_actions(&mut self) { @@ -161,7 +176,10 @@ impl Workflow { match item { Item::TypeHere | Item::Keyboard => { self.stage = Stage::KeyboardOpening; - self.keyboard = crate::scan_keyboard::Keyboard::new(cfg!(target_os = "macos")); + self.keyboard = crate::scan_keyboard::Keyboard::configured( + cfg!(target_os = "macos"), + self.point.config.keyboard_scan, + ); return Some(Request::OpenKeyboard { point: (item == Item::TypeHere).then_some(self.source), }); @@ -173,7 +191,7 @@ impl Workflow { } else { Kind::More }; - let next = Menu::new(kind, self.point.config.block_interval_ms); + let next = Menu::configured(kind, self.point.config.menu_scan); self.parent_menu .push(std::mem::replace(&mut self.menu, next)); } @@ -206,7 +224,7 @@ impl Workflow { }); } Item::Scroll | Item::Drag => { - let next = Menu::new(Kind::Scroll, self.point.config.block_interval_ms); + let next = Menu::configured(Kind::Scroll, self.point.config.menu_scan); self.parent_menu .push(std::mem::replace(&mut self.menu, next)); if item == Item::Drag { @@ -258,7 +276,7 @@ impl Technique for Workflow { } self.pending = None; self.stage = Stage::Menu; - self.menu = Menu::new(Kind::Actions, self.point.config.block_interval_ms); + self.menu = Menu::configured(Kind::Actions, self.point.config.menu_scan); self.parent_menu.clear(); self.menu.suspend(); self.error = Some(message); @@ -276,7 +294,10 @@ impl Technique for Workflow { self.point.start(); } fn reset(&mut self) { - self.keyboard = crate::scan_keyboard::Keyboard::new(cfg!(target_os = "macos")); + self.keyboard = crate::scan_keyboard::Keyboard::configured( + cfg!(target_os = "macos"), + self.point.config.keyboard_scan, + ); self.stage = Stage::Idle; self.parent_menu.clear(); self.pending = None; @@ -292,6 +313,9 @@ impl Technique for Workflow { fn complete_on_selection(&self) -> bool { false } + fn automatic(&self, _fallback: bool) -> bool { + self.active_preferences().automatic + } fn auto_selecting(&self) -> bool { self.stage == Stage::Countdown } @@ -317,8 +341,7 @@ impl Technique for Workflow { self.destination = point; self.open(Kind::ConfirmDrag); } else { - let (target, menu) = - selection_policy(point, self.point.config.block_interval_ms); + let (target, menu) = selection_policy(point, self.point.config.menu_scan); self.source = target; self.menu = menu; self.elapsed = 0; @@ -354,7 +377,7 @@ impl Technique for Workflow { match self.stage { Stage::Keyboard => self .keyboard - .advance(ms, self.point.config.block_interval_ms), + .advance(ms, self.point.config.keyboard_scan.interval_ms), Stage::Point | Stage::Destination => { self.point.advance(ms); if self.point.exhausted() { @@ -511,9 +534,11 @@ impl Technique for Workflow { }); } } - frame.color = self.point.config.scanner_color; + let preferences = self.active_preferences(); + frame.color = preferences.color; for tile in &mut frame.tiles { tile.color = frame.color; + tile.thickness = preferences.thickness; } frame } @@ -863,6 +888,9 @@ mod tests { for color in [Red, Green, Blue, Yellow, White] { let mut s = session(false); s.technique.point.config.scanner_color = color; + s.technique.point.config.scan.color = color; + s.technique.point.config.menu_scan.color = color; + s.technique.point.config.keyboard_scan.color = color; open(&mut s); for kind in [Kind::Actions, Kind::Scroll, Kind::ConfirmDrag] { s.technique.open(kind); @@ -1035,4 +1063,37 @@ mod tests { assert_eq!(s.technique.menu.kind, Kind::Actions); assert_eq!(s.take_selection(), None); } + #[test] + fn session_uses_each_areas_automatic_setting_without_ignoring_pause_or_hold() { + use crate::scan_preferences::Area; + let mut config = crate::point_scan::Config { + automatic: false, + ..Default::default() + }; + config.scan_preferences.menu.automatic = Some(true); + config.scan_preferences.menu.interval_ms = Some(250); + config.scan_preferences.keyboard.automatic = Some(false); + let mut s = session(false); + s.technique.apply_config(config.point(), false); + s.action(Action::Select); + let initial = s.frame(); + s.tick(250, false); + assert_eq!(s.frame(), initial); + s.action(Action::Select); + s.action(Action::Select); + let menu = s.frame(); + s.tick(250, true); + assert_eq!(s.frame(), menu); + s.action(Action::Pause); + s.tick(250, false); + assert_eq!(s.frame(), menu); + s.action(Action::Pause); + s.tick(250, false); + assert_ne!(s.frame(), menu); + assert!(config.resolved(Area::Menu).automatic); + s.technique.stage = Stage::Keyboard; + let keyboard = s.frame(); + s.tick(250, false); + assert_eq!(s.frame(), keyboard); + } } diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index 422e4108..001d2d3d 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -353,6 +353,7 @@ pub fn apply(app: &AppHandle) { .state::() .view() .config + .switches() .automatic; c.data .lock() @@ -444,7 +445,7 @@ pub fn route( device, payload, interval, - view.config.automatic, + view.config.switches().automatic, now, )); } diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs index c86b6b14..d2c01c05 100644 --- a/src-tauri/src/scan_items.rs +++ b/src-tauri/src/scan_items.rs @@ -1,6 +1,7 @@ use crate::{ + scan_preferences::{Direction, Pattern, Resolved}, scan_tree::{Navigator, Node, Selection}, - scanning::{Action, Interval, MAX_SCAN_CYCLES}, + scanning::{Action, Interval}, }; #[derive(Clone, Copy)] @@ -21,6 +22,7 @@ impl Policy { pub struct ItemScanner { pub nav: Navigator, + pub options: Resolved, pub suspended: bool, interval: Interval, cycles: usize, @@ -33,6 +35,7 @@ impl ItemScanner { pub fn new(nodes: Vec>, policy: Policy) -> Self { Self { nav: Navigator::new(nodes), + options: Resolved::default(), suspended: false, interval: Interval::default(), cycles: 0, @@ -57,9 +60,45 @@ impl ItemScanner { }) .collect() } + #[cfg(test)] pub fn rows(rows: &[Vec], policy: Policy) -> Self { Self::new(Self::nodes(rows), policy) } + pub fn configured_rows(rows: &[Vec], policy: Policy, options: Resolved) -> Self { + let nodes = if options.pattern == Pattern::Linear { + rows.iter().flatten().cloned().map(Node::Leaf).collect() + } else { + Self::nodes(rows) + }; + let mut scanner = Self::new(nodes, policy); + scanner.options = options; + scanner.restart(); + scanner + } + pub fn row_scan(&self) -> bool { + self.options.pattern == Pattern::Grouped && self.nav.path().is_empty() + } + pub fn position(&self, rows: &[Vec]) -> (usize, Option) { + if self.options.pattern == Pattern::Linear { + let mut index = self.nav.index(); + for (r, row) in rows.iter().enumerate() { + if index < row.len() { + return (r, Some(index)); + } + index -= row.len(); + } + (0, None) + } else { + ( + self.nav.path().first().copied().unwrap_or(self.nav.index()), + if self.nav.path().is_empty() || self.nav.escaping() { + None + } else { + Some(self.nav.index()) + }, + ) + } + } pub fn pending(&self) -> bool { self.pending } @@ -89,20 +128,26 @@ impl ItemScanner { pub fn restart(&mut self) { self.pending = false; self.nav.reset(); - self.forward = true; + self.forward = self.options.direction == Direction::Forward; + if !self.forward { + self.nav.start_at_end(); + } self.restart_interval(); } pub fn skip(&mut self) { self.nav.step(self.forward); } + pub fn skip_automatic(&mut self) { + if self.nav.step(self.forward) { + self.cycles += 1; + self.suspended = self.options.exhausted(self.cycles); + } + } pub fn advance(&mut self, ms: u64, period: u64) -> bool { if self.pending || self.suspended || !self.interval.elapsed(ms, period) { return false; } - if self.nav.step(self.forward) { - self.cycles += 1; - self.suspended = self.cycles >= MAX_SCAN_CYCLES; - } + self.skip_automatic(); true } pub fn handle(&mut self, action: Action) -> Option { @@ -124,7 +169,15 @@ impl ItemScanner { self.restart_interval(); match self.nav.select() { Selection::Leaf(value) => return Some(value), - Selection::Entered | Selection::Escaped => self.forward = true, + Selection::Entered => { + self.forward = self.options.direction == Direction::Forward; + if !self.forward { + self.nav.start_at_end(); + } + } + Selection::Escaped => { + self.forward = self.options.direction == Direction::Forward + } Selection::None => {} } } @@ -148,6 +201,23 @@ impl ItemScanner { impl ItemScanner { pub fn replace(&mut self, nodes: Vec>) -> bool { + fn leaves(nodes: Vec>, output: &mut Vec>) { + for node in nodes { + match node { + Node::Leaf(_) => output.push(node), + Node::Branch(children) | Node::Group { children, .. } => { + leaves(children, output) + } + } + } + } + let nodes = if self.options.pattern == Pattern::Linear { + let mut flat = vec![]; + leaves(nodes, &mut flat); + flat + } else { + nodes + }; let Some(preserved) = self.nav.replace(nodes) else { return false; }; @@ -374,4 +444,68 @@ mod tests { let mut scan = ItemScanner::rows(&[vec!["close"]], Policy::MENU); assert_eq!(scan.handle(Action::Select), Some("close")); } + #[test] + fn linear_scanning_visits_each_item_once_and_reports_its_visual_position() { + let rows = vec![vec!["copy", "paste"], vec!["save"]]; + let mut scan = ItemScanner::configured_rows( + &rows, + Policy::MENU, + Resolved { + pattern: Pattern::Linear, + ..Default::default() + }, + ); + for (r, c, expected) in [(0, 0, "copy"), (0, 1, "paste"), (1, 0, "save")] { + assert!(!scan.row_scan()); + assert_eq!(scan.position(&rows), (r, Some(c))); + assert_eq!(scan.handle(Action::Select), Some(expected)); + scan.handle(Action::Next); + } + assert_eq!(scan.position(&rows), (0, Some(0))); + } + + #[test] + fn reverse_starts_at_the_last_group_and_last_item() { + let rows = vec![vec!["copy", "paste"], vec!["save", "close"]]; + let mut scan = ItemScanner::configured_rows( + &rows, + Policy::KEYBOARD, + Resolved { + direction: Direction::Reverse, + ..Default::default() + }, + ); + assert_eq!(scan.position(&rows), (1, None)); + scan.handle(Action::Select); + assert_eq!(scan.position(&rows), (1, Some(1))); + assert_eq!(scan.handle(Action::Select), Some("close")); + scan.advance(500, 500); + assert_eq!(scan.handle(Action::Select), Some("save")); + scan.restart(); + assert_eq!(scan.position(&rows), (1, None)); + } + + #[test] + fn configurable_pass_limits_and_unlimited_do_not_change_resume_semantics() { + for limit in [0, 1, 2, 3, 5] { + let mut scan = ItemScanner::configured_rows( + &[vec!["copy", "paste"]], + Policy::MENU, + Resolved { + pattern: Pattern::Linear, + pass_limit: limit, + ..Default::default() + }, + ); + let ticks = if limit == 0 { 40 } else { limit * 2 }; + for _ in 0..ticks { + scan.advance(500, 500); + } + assert_eq!(scan.suspended, limit != 0); + if limit != 0 { + assert_eq!(scan.handle(Action::Select), None); + assert!(!scan.suspended); + } + } + } } diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 3dda257e..922a0e3a 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -186,9 +186,13 @@ pub struct Keyboard { prefer_predictions: bool, } impl Keyboard { + #[cfg(test)] pub fn new(mac: bool) -> Self { + Self::configured(mac, crate::scan_preferences::Resolved::default()) + } + pub fn configured(mac: bool, options: crate::scan_preferences::Resolved) -> Self { let rows = rows(Page::Letters, mac); - let scan = ItemScanner::rows(&rows, Policy::KEYBOARD); + let scan = ItemScanner::configured_rows(&rows, Policy::KEYBOARD, options); Self { page: Page::Letters, modifiers: [Modifier::Off; 4], @@ -221,37 +225,29 @@ impl Keyboard { self.scan.replace(ItemScanner::nodes(&self.rows)); self.scan.restart(); self.activation = None; - self.skip_disabled(); + self.skip_disabled(false); } fn prediction_row_active(&self) -> bool { self.prediction_enabled && self.page == Page::Letters - && self - .scan - .nav - .path() - .first() - .copied() - .unwrap_or(self.scan.nav.index()) - == 0 + && self.scan.position(&self.rows).0 == 0 } pub fn predictions(&mut self, batch: Option, failed: bool) { self.prediction_failed = failed; if batch.is_none() { self.predictions = None; self.queued_predictions = None; - self.skip_disabled(); return; } - if self.prediction_row_active() && self.predictions.is_some() { + if self.prediction_row_active() { if self.predictions.as_ref().map(|b| b.token) != batch.as_ref().map(|b| b.token) { self.predictions = None; self.queued_predictions = batch; - self.skip_disabled(); } } else { self.predictions = batch; if self.prefer_predictions + && self.scan.options.direction == crate::scan_preferences::Direction::Forward && self .predictions .as_ref() @@ -268,18 +264,21 @@ impl Keyboard { return false; } let count = self.predictions.as_ref().map_or(0, |b| b.words.len()); - if self.scan.nav.path().is_empty() { - count == 0 - } else { - self.scan.nav.index() >= count - } + self.scan + .position(&self.rows) + .1 + .map_or(count == 0, |column| column >= count) } - fn skip_disabled(&mut self) { - for _ in 0..8 { + fn skip_disabled(&mut self, automatic: bool) { + for _ in 0..self.rows.iter().map(Vec::len).sum::() + 1 { if !self.disabled() { break; } - self.scan.skip(); + if automatic { + self.scan.skip_automatic(); + } else { + self.scan.skip(); + } } if !self.prediction_row_active() { if let Some(batch) = self.queued_predictions.take() { @@ -293,13 +292,13 @@ impl Keyboard { fn restart(&mut self) { self.prefer_predictions = true; self.scan.restart(); - self.skip_disabled(); + self.skip_disabled(false); } pub fn advance(&mut self, ms: u64, period: u64) { if self.scan.advance(ms, period) { self.prefer_predictions = false; + self.skip_disabled(true); } - self.skip_disabled(); } pub fn handle(&mut self, action: Action) -> Option { if self.scan.pending() { @@ -313,10 +312,15 @@ impl Keyboard { return None; } self.prefer_predictions = false; + if self.disabled() && action == Action::Select { + self.scan.restart_interval(); + self.skip_disabled(false); + return None; + } if let Some(key) = self.scan.handle(action) { return self.choose(key); } - self.skip_disabled(); + self.skip_disabled(false); None } fn choose(&mut self, key: Key) -> Option { @@ -492,6 +496,7 @@ impl Keyboard { let header_height = row_height * 0.9; let mut frame = Frame::default(); frame.tiles.push(FrameTile { + thickness: Default::default(), color, text: String::new(), icon: Item::KeyboardKey, @@ -512,14 +517,8 @@ impl Keyboard { let x = x + padding; let y = y + padding; let width = content_width; - let row_scan = self.scan.nav.path().is_empty(); - let active_row = self - .scan - .nav - .path() - .first() - .copied() - .unwrap_or(self.scan.nav.index()); + let row_scan = self.scan.row_scan(); + let (active_row, active_column) = self.scan.position(&self.rows); for (r, row) in self.rows.iter().enumerate() { let total_weight: f64 = row.iter().copied().map(Self::weight).sum(); let cell_unit = (width - gap * (row.len() - 1) as f64).max(1.0) / total_weight; @@ -527,6 +526,7 @@ impl Keyboard { for (c, key) in row.iter().enumerate() { let key_width = cell_unit * Self::weight(*key); frame.tiles.push(FrameTile { + thickness: Default::default(), color, text: self.label(*key), icon: Item::KeyboardKey, @@ -539,9 +539,10 @@ impl Keyboard { }, scale, selected: !self.scan.suspended + && !self.disabled() && !self.scan.nav.escaping() && r == active_row - && (row_scan || c == self.scan.nav.index()), + && active_column.is_none_or(|column| c == column), }); left += key_width + gap; } @@ -551,25 +552,28 @@ impl Keyboard { Page::Functions => "Navigation", Page::Numbers => "Numbers", }; - let text = if self.prediction_failed { - "Predictions unavailable · Keyboard ready".to_owned() - } else if self.error { + let text = if self.error { "Input failed · Select to try again".to_owned() } else if self.scan.suspended { "Keyboard paused · Select to resume".to_owned() } else if self.scan.nav.escaping() { "Back to rows · Select to return".to_owned() + } else if self.disabled() { + "Suggestions updating · Select to continue".to_owned() + } else if self.prediction_failed { + "Predictions unavailable · Keyboard ready".to_owned() } else if row_scan { format!("{} · Select a row", page) } else { format!( "{} · Select {}", page, - self.label(self.rows[active_row][self.scan.nav.index()]) + self.label(self.rows[active_row][active_column.unwrap_or(0)]) .replace('\n', " ") ) }; frame.tiles.push(FrameTile { + thickness: Default::default(), color, text, icon: Item::KeyboardKey, @@ -594,7 +598,6 @@ impl Keyboard { #[cfg(test)] mod tests { use super::*; - use crate::scanning::MAX_SCAN_CYCLES; #[test] fn arriving_predictions_receive_a_full_scan_interval() { let mut keyboard = Keyboard::new(false); @@ -821,7 +824,7 @@ mod tests { k.succeeded(); assert!(k.scan.nav.path().is_empty()); assert_eq!(k.scan.nav.index(), 0); - for _ in 0..k.rows.len() * MAX_SCAN_CYCLES { + for _ in 0..k.rows.len() * k.scan.options.pass_limit { k.advance(100, 100); } assert!(k.suspended()); @@ -851,4 +854,170 @@ mod tests { } } } + #[test] + fn reverse_scanning_counts_passes_across_empty_prediction_slots() { + use crate::scan_preferences::{Direction, Pattern, Resolved}; + for pattern in [Pattern::Grouped, Pattern::Linear] { + let mut k = Keyboard::configured( + false, + Resolved { + direction: Direction::Reverse, + pattern, + pass_limit: 1, + ..Default::default() + }, + ); + k.enable_predictions(true); + let steps = if pattern == Pattern::Grouped { + k.rows.len() - 1 + } else { + k.rows.iter().skip(1).map(Vec::len).sum() + }; + for _ in 0..steps - 1 { + k.advance(100, 100); + assert!(!k.suspended()); + } + k.advance(100, 100); + assert!(k.suspended()); + assert!(k.handle(Action::Select).is_none()); + assert!(!k.suspended()); + for _ in 0..steps * 2 { + k.handle(Action::Back); + } + assert!(!k.suspended()); + } + } + #[test] + fn prediction_warning_does_not_hide_resume_or_error_instructions() { + let mut k = Keyboard::new(false); + k.prediction_failed = true; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 800.0, + }; + k.scan.suspended = true; + let frame = k.frame(screen, 1.0, ScannerColor::Blue); + assert!(frame + .tiles + .iter() + .any(|tile| tile.text.contains("Select to resume"))); + k.failed(); + let frame = k.frame(screen, 1.0, ScannerColor::Blue); + assert!(frame + .tiles + .iter() + .any(|tile| tile.text.contains("Select to try again"))); + } + #[test] + fn arriving_predictions_preserve_reverse_origin_and_full_pass() { + use crate::scan_preferences::{Direction, Pattern, Resolved}; + for pattern in [Pattern::Grouped, Pattern::Linear] { + let mut k = Keyboard::configured( + false, + Resolved { + direction: Direction::Reverse, + pattern, + pass_limit: 1, + ..Default::default() + }, + ); + k.enable_predictions(true); + let origin = k.scan.nav.index(); + k.advance(490, 500); + k.predictions( + Some(crate::prediction::worker::Batch { + token: 1, + words: vec!["hello".into(), "world".into()], + }), + false, + ); + assert_eq!(k.scan.nav.index(), origin); + k.advance(10, 500); + assert_ne!(k.scan.nav.index(), origin); + assert!(!k.suspended()); + let count = if pattern == Pattern::Grouped { + k.rows.len() + } else { + k.rows.iter().skip(1).map(Vec::len).sum::() + 2 + }; + for _ in 1..count - 1 { + k.advance(500, 500); + assert!(!k.suspended()); + } + k.advance(500, 500); + assert!(k.suspended()); + } + } + #[test] + fn invalid_predictions_wait_for_permitted_movement_and_count_reverse_wraps() { + use crate::scan_preferences::{Direction, Pattern, Resolved}; + for pattern in [Pattern::Grouped, Pattern::Linear] { + for replacement in [false, true] { + for manual in [false, true] { + let mut k = Keyboard::configured( + false, + Resolved { + direction: Direction::Reverse, + pattern, + pass_limit: 1, + automatic: !manual, + ..Default::default() + }, + ); + k.enable_predictions(true); + k.predictions( + Some(crate::prediction::worker::Batch { + token: 1, + words: vec!["hello".into(), "world".into()], + }), + false, + ); + let count = if pattern == Pattern::Grouped { + k.rows.len() + } else { + k.rows.iter().skip(1).map(Vec::len).sum::() + 2 + }; + for _ in 0..count - 1 { + k.advance(500, 500); + } + assert!(k.prediction_row_active()); + k.advance(490, 500); + let position = k.scan.nav.index(); + let next = replacement.then(|| crate::prediction::worker::Batch { + token: 2, + words: vec!["new".into()], + }); + for _ in 0..3 { + k.predictions(next.clone(), false); + } + assert_eq!(k.scan.nav.index(), position); + assert!(!k.suspended()); + let frame = k.frame( + Rect { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 800.0, + }, + 1.0, + ScannerColor::Blue, + ); + assert!(!frame.tiles.iter().any(|tile| tile.selected)); + if manual { + assert!(k.handle(Action::Select).is_none()); + assert!(!k.suspended()); + assert!(!k.prediction_row_active()); + } else { + k.advance(9, 500); + assert_eq!(k.scan.nav.index(), position); + assert!(!k.suspended()); + k.advance(1, 500); + assert!(k.suspended()); + } + } + } + } + } } diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index 4e26f2f0..40bce8f3 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -102,14 +102,24 @@ pub struct Menu { period: u64, } impl Menu { + #[cfg(test)] pub fn new(kind: Kind, period: u64) -> Self { + Self::configured( + kind, + crate::scan_preferences::Resolved { + interval_ms: period, + ..Default::default() + }, + ) + } + pub fn configured(kind: Kind, options: crate::scan_preferences::Resolved) -> Self { let rows = kind.rows(); - let scan = ItemScanner::rows(&rows, Policy::MENU); + let scan = ItemScanner::configured_rows(&rows, Policy::MENU, options); Self { kind, rows, scan, - period, + period: options.interval_ms, } } pub fn suspended(&self) -> bool { @@ -143,13 +153,7 @@ impl Menu { let height = logical_height * scale; let panel = place(point, screen, width, height, 20.0 * scale); let mut frame = Frame::default(); - let active_row = self - .scan - .nav - .path() - .first() - .copied() - .unwrap_or(self.scan.nav.index()); + let (active_row, active_column) = self.scan.position(&self.rows); for (r, row) in self.rows.iter().enumerate() { let tile_width = 180.0 * scale; for (c, item) in row.iter().enumerate() { @@ -159,11 +163,9 @@ impl Menu { width: 168.0 * scale, height: 168.0 * scale, }; - let selected = r == active_row - && (self.scan.nav.path().is_empty() - || self.scan.nav.escaping() - || c == self.scan.nav.index()); + let selected = r == active_row && active_column.is_none_or(|column| column == c); frame.tiles.push(FrameTile { + thickness: Default::default(), keyboard: None, color: Default::default(), text: item.label().into(), @@ -378,20 +380,25 @@ impl Setting { pub fn apply(self, config: &mut crate::point_scan::Config) { const RATES: &[u64] = &[250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000]; match self { - Self::FasterScan => { - config.block_interval_ms = RATES - .iter() - .rev() - .copied() - .find(|v| *v < config.block_interval_ms) - .unwrap_or(RATES[0]) - } - Self::SlowerScan => { - config.block_interval_ms = RATES - .iter() - .copied() - .find(|v| *v > config.block_interval_ms) - .unwrap_or(5000) + Self::FasterScan | Self::SlowerScan => { + let current = config + .resolved(crate::scan_preferences::Area::Menu) + .interval_ms; + let next = if self == Self::FasterScan { + RATES + .iter() + .rev() + .copied() + .find(|v| *v < current) + .unwrap_or(RATES[0]) + } else { + RATES.iter().copied().find(|v| *v > current).unwrap_or(5000) + }; + if config.scan_preferences.menu.interval_ms.is_some() { + config.scan_preferences.menu.interval_ms = Some(next); + } else { + config.block_interval_ms = next; + } } Self::FasterLine => config.speed = (config.speed + 1).min(4), Self::SlowerLine => config.speed = config.speed.saturating_sub(1), @@ -720,4 +727,17 @@ mod tests { m.advance(250); assert_eq!(m.scan.nav.index(), 3); } + #[test] + fn menu_speed_changes_respect_the_area_override() { + let mut c = crate::point_scan::Config::default(); + c.scan_preferences.menu.interval_ms = Some(500); + Setting::SlowerScan.apply(&mut c); + assert_eq!(c.scan_preferences.menu.interval_ms, Some(750)); + assert_eq!(c.block_interval_ms, 1000); + assert_eq!(c.point().block_interval_ms, 1000); + c.scan_preferences.menu.interval_ms = None; + Setting::FasterScan.apply(&mut c); + assert_eq!(c.block_interval_ms, 750); + assert_eq!(c.scan_preferences.menu.interval_ms, None); + } } diff --git a/src-tauri/src/scan_preferences.rs b/src-tauri/src/scan_preferences.rs new file mode 100644 index 00000000..a6dd65c4 --- /dev/null +++ b/src-tauri/src/scan_preferences.rs @@ -0,0 +1,192 @@ +use crate::scanning::ScannerColor; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Direction { + #[default] + Forward, + Reverse, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Pattern { + #[default] + Grouped, + Linear, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Thickness { + Thin, + #[default] + Standard, + Thick, +} +impl Thickness { + pub fn scale(self) -> f64 { + match self { + Self::Thin => 0.5, + Self::Standard => 1.0, + Self::Thick => 2.0, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Area { + Point, + Menu, + Keyboard, +} +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct Overrides { + pub automatic: Option, + pub interval_ms: Option, + pub direction: Option, + pub pass_limit: Option, + pub pattern: Option, + pub color: Option, + pub thickness: Option, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Preferences { + pub direction: Direction, + pub pass_limit: usize, + pub pattern: Pattern, + pub thickness: Thickness, + pub point: Overrides, + pub menu: Overrides, + pub keyboard: Overrides, +} +impl Default for Preferences { + fn default() -> Self { + Self { + direction: Direction::Forward, + pass_limit: 3, + pattern: Pattern::Grouped, + thickness: Thickness::Standard, + point: Default::default(), + menu: Default::default(), + keyboard: Default::default(), + } + } +} +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Resolved { + pub automatic: bool, + pub interval_ms: u64, + pub direction: Direction, + pub pass_limit: usize, + pub pattern: Pattern, + pub color: ScannerColor, + pub thickness: Thickness, +} +impl Default for Resolved { + fn default() -> Self { + Preferences::default().resolve(Area::Point, true, 1000, ScannerColor::default()) + } +} +impl Resolved { + pub fn exhausted(self, passes: usize) -> bool { + self.pass_limit != 0 && passes >= self.pass_limit + } +} +fn valid_passes(value: usize) -> bool { + matches!(value, 0 | 1 | 2 | 3 | 5) +} +impl Preferences { + pub fn resolve( + &self, + area: Area, + automatic: bool, + interval_ms: u64, + color: ScannerColor, + ) -> Resolved { + let local = match area { + Area::Point => self.point, + Area::Menu => self.menu, + Area::Keyboard => self.keyboard, + }; + Resolved { + automatic: local.automatic.unwrap_or(automatic), + interval_ms: local + .interval_ms + .filter(|v| (250..=5000).contains(v)) + .unwrap_or(interval_ms), + direction: local.direction.unwrap_or(self.direction), + pass_limit: local.pass_limit.filter(|v| valid_passes(*v)).unwrap_or( + if valid_passes(self.pass_limit) { + self.pass_limit + } else { + 3 + }, + ), + pattern: if area == Area::Point { + Pattern::Grouped + } else { + local.pattern.unwrap_or(self.pattern) + }, + color: local.color.unwrap_or(color), + thickness: local.thickness.unwrap_or(self.thickness), + } + } +} +pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + let mut preferences: Preferences = serde_json::from_value(value).unwrap_or_default(); + if !valid_passes(preferences.pass_limit) { + preferences.pass_limit = 3; + } + for local in [ + &mut preferences.point, + &mut preferences.menu, + &mut preferences.keyboard, + ] { + local.interval_ms = local.interval_ms.filter(|v| (250..=5000).contains(v)); + local.pass_limit = local.pass_limit.filter(|v| valid_passes(*v)); + } + Ok(preferences) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn areas_inherit_and_override_independently() { + let mut settings = Preferences::default(); + settings.keyboard.automatic = Some(false); + settings.keyboard.interval_ms = Some(250); + settings.keyboard.pass_limit = Some(0); + settings.keyboard.color = Some(ScannerColor::Red); + let keyboard = settings.resolve(Area::Keyboard, true, 1500, ScannerColor::Green); + assert!(!keyboard.automatic); + assert_eq!(keyboard.interval_ms, 250); + assert!(!keyboard.exhausted(10000)); + assert_eq!(keyboard.color, ScannerColor::Red); + for area in [Area::Point, Area::Menu] { + let inherited = settings.resolve(area, true, 1500, ScannerColor::Green); + assert!(inherited.automatic); + assert_eq!(inherited.interval_ms, 1500); + assert_eq!(inherited.color, ScannerColor::Green); + } + } + #[test] + fn invalid_numeric_values_use_validated_defaults() { + let settings = Preferences { + pass_limit: 99, + menu: Overrides { + interval_ms: Some(0), + pass_limit: Some(9), + ..Default::default() + }, + ..Default::default() + }; + let resolved = settings.resolve(Area::Menu, true, 1000, ScannerColor::Blue); + assert_eq!(resolved.interval_ms, 1000); + assert_eq!(resolved.pass_limit, 3); + } +} diff --git a/src-tauri/src/scan_tile.rs b/src-tauri/src/scan_tile.rs index e9a4fb08..f05a450d 100644 --- a/src-tauri/src/scan_tile.rs +++ b/src-tauri/src/scan_tile.rs @@ -42,7 +42,17 @@ pub fn bitmap(tile: &FrameTile) -> Result { } else { base }; - let inset = (2.0 * tile.scale) as f32; + let stroke_width = if tile.selected && !style.is_some_and(|s| s.row_scan) { + 3.0 + } else { + 1.0 + } * tile.scale as f32 + * if tile.selected { + tile.thickness.scale() as f32 + } else { + 1.0 + }; + let inset = ((2.0 * tile.scale) as f32).max(stroke_width / 2.0); let radius = (8.0 * tile.scale) as f32; let width = bitmap.width() as f32; let height = bitmap.height() as f32; @@ -66,11 +76,7 @@ pub fn bitmap(tile: &FrameTile) -> Result { &path, &paint, &Stroke { - width: if tile.selected && !style.is_some_and(|s| s.row_scan) { - 3.0 - } else { - 1.0 - } * tile.scale as f32, + width: stroke_width, ..Default::default() }, Transform::identity(), @@ -104,12 +110,16 @@ pub fn bitmap(tile: &FrameTile) -> Result { paint.set_color_rgba8(r, g, b, 255); if tile.selected { let mut border = PathBuilder::new(); - border.push_rect(tiny_skia::Rect::from_xywh(2.0, 2.0, 164.0, 164.0).unwrap()); + let thickness = 4.0 * tile.thickness.scale() as f32; + let inset = thickness / 2.0; + border.push_rect( + tiny_skia::Rect::from_xywh(inset, inset, 168.0 - thickness, 168.0 - thickness).unwrap(), + ); pixmap.stroke_path( &border.finish().unwrap(), &paint, &Stroke { - width: 4.0, + width: thickness, ..Default::default() }, transform, @@ -703,6 +713,7 @@ mod tests { use crate::scanning::{Rect, ScannerColor::*}; for color in [Red, Green, Blue, Yellow, White] { let mut tile = FrameTile { + thickness: Default::default(), keyboard: None, color, text: "Click".into(), @@ -804,6 +815,7 @@ mod tests { Setting(S::GridMode), ] { let mut tile = FrameTile { + thickness: Default::default(), keyboard: None, color: Default::default(), text: icon.label().into(), diff --git a/src-tauri/src/scan_tree.rs b/src-tauri/src/scan_tree.rs index 6c12adaf..f69e4ba1 100644 --- a/src-tauri/src/scan_tree.rs +++ b/src-tauri/src/scan_tree.rs @@ -73,6 +73,10 @@ impl Navigator { pub fn escaping(&self) -> bool { self.escaping } + pub fn start_at_end(&mut self) { + self.index = self.siblings().len().saturating_sub(1); + self.escaping = false; + } pub fn reset(&mut self) { self.path.clear(); self.index = 0; diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 3bc1251d..cf04a252 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -260,6 +260,7 @@ pub struct KeyboardTileStyle { } #[derive(Debug, Clone, PartialEq)] pub struct FrameTile { + pub thickness: crate::scan_preferences::Thickness, pub color: ScannerColor, pub text: String, pub rect: Rect, @@ -282,13 +283,12 @@ pub struct FrameLabel { pub hud: Option, } -/// Automatic movement gives up after this many full passes of the current -/// phase without a selection, so an unattended scan does not sweep forever. -pub const MAX_SCAN_CYCLES: usize = 3; - pub trait Technique { type Selection; type Phase: Clone + Default + PartialEq + Serialize; + fn automatic(&self, fallback: bool) -> bool { + fallback + } fn auto_selecting(&self) -> bool { false } @@ -300,7 +300,7 @@ pub trait Technique { fn reset(&mut self); fn frame(&self) -> Frame; fn phase(&self) -> Self::Phase; - /// True once automatic movement has completed `MAX_SCAN_CYCLES` passes + /// True once automatic movement has completed the configured number of passes /// without a selection; the session then resets and waits for Select. fn pausable(&self) -> bool { true @@ -388,7 +388,9 @@ impl Session { self.technique.update( elapsed_ms.min(MAX_ELAPSED_MS), UpdateContext { - movement_enabled: self.automatic && !self.paused && !select_held, + movement_enabled: self.technique.automatic(self.automatic) + && !self.paused + && !select_held, paused: self.paused, switch_held: select_held, }, diff --git a/src/App.test.tsx b/src/App.test.tsx index bf617cee..b0d5ccf6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -6,6 +6,7 @@ import type { BluetoothState, SwitchProfile } from "./types"; import * as switchHooks from "./scanning/useSwitches"; import * as scanHooks from "./scanning/useScanning"; +import { defaultScanPreferences } from "./scanning/preferences"; const defaultBrowserSettings = structuredClone(browserState.settings); @@ -37,13 +38,14 @@ describe("Switchify PC shell", () => { browserState.accessibility = "granted"; browserState.bluetooth = "unsupported"; vi.spyOn(switchHooks, "useSwitches").mockReturnValue(localSwitches()); - const scanning: scanHooks.ScanningController = { config: scanHooks.defaultPointScanConfig, state: { config: scanHooks.defaultPointScanConfig, supported: true, enabled: true, paused: false, phase: "idle", message: "Ready" }, pending: 0, error: null, unsaved: false, update: vi.fn(), retry: vi.fn() }; + const scanning: scanHooks.ScanningController = { config: { ...scanHooks.defaultPointScanConfig, scanPreferences: { ...defaultScanPreferences, point: { automatic: false } } }, state: { config: scanHooks.defaultPointScanConfig, supported: true, enabled: true, paused: false, phase: "idle", message: "Ready" }, pending: 0, error: null, unsaved: false, update: vi.fn(), retry: vi.fn() }; vi.spyOn(scanHooks, "useScanning").mockReturnValue(scanning); let receive: ((state: typeof browserState) => void) | undefined; vi.spyOn(api, "onState").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); render(); await screen.findByRole("heading", { name: "Ready for switch control" }); expect(screen.getByText("1 saved · Select assigned")).toBeInTheDocument(); + expect(screen.getByText("Manual scanning · Line only")).toBeInTheDocument(); scanning.state = { ...scanning.state!, paused: true }; act(() => receive?.({ ...structuredClone(browserState), bluetooth: "connected" })); expect(screen.getByText("Local scanning is paused while an Android device is connected.")).toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index e62c75a5..b0852801 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { updateDescription, updateInFlight, updateLiveness, updateProgress, upda import { TabPanel, Tabs } from "./Tabs"; import { useSwitches, type SwitchController } from "./scanning/useSwitches"; import { useScanning, type ScanningController } from "./scanning/useScanning"; +import { areaOptions } from "./scanning/preferences"; import { SwitchesSection } from "./settings/SwitchesSection"; import { ScanningSection } from "./settings/ScanningSection"; @@ -72,7 +73,7 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi

Input access

{state.accessibility === "required" && }

Configured switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

-

Scanning

{scanning.config.automatic ? "Automatic scanning" : "Manual scanning"} · {scanning.config.mode === "line" ? "Line only" : "Grid then line"}

+

Scanning

{areaOptions(scanning.config, "point").automatic ? "Automatic scanning" : "Manual scanning"} · {scanning.config.mode === "line" ? "Line only" : "Grid then line"}

Android connection

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" && }
; diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index c82aa237..45964bf3 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -48,7 +48,7 @@ afterEach(() => { it("shows the backend's reason while scanning is off and offers no toggle", async () => { render(); await screen.findByText("Scanning starts once a switch has the Select action."); - expect(screen.queryByRole("button", { name: /point scan/i })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); expect(screen.getByRole("button", { name: "Line only" })).toBeEnabled(); }); @@ -60,6 +60,7 @@ it("saves settings without an enabled flag and keeps them editable while scannin ); render(); await screen.findByText("Ready to begin."); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { @@ -91,6 +92,7 @@ it("keeps newer edits across old events", async () => { resolveSave = resolve; }), ); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(resolveSave).toBeTypeOf("function")); fireEvent.change(screen.getByLabelText("Grid size"), { @@ -114,6 +116,7 @@ it("retains failed edits and offers a retry", async () => { render(); await screen.findByText(initial.message); mocks.invoke.mockRejectedValueOnce("Disk is full."); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await screen.findByText("Disk is full."); expect( @@ -142,6 +145,7 @@ it("keeps pending saves when its settings panel unmounts", async () => { resolveSave = resolve; }), ); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(resolveSave).toBeTypeOf("function")); view.rerender(); @@ -149,6 +153,7 @@ it("keeps pending saves when its settings panel unmounts", async () => { resolveSave({ ...initial, config: { ...initial.config, mode: "grid" } }), ); view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); expect( screen.getByRole("button", { name: "Grid then line" }), ).toHaveAttribute("aria-pressed", "true"); @@ -164,6 +169,7 @@ it("keeps a newer runtime event when an older save response arrives", async () = resolveSave = resolve; }), ); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(resolveSave).toBeTypeOf("function")); act(() => @@ -231,6 +237,7 @@ it("retains a failed colour selection for retry", async () => { it("saves auto selection separately from scan movement and validates its delay", async () => { render(); await screen.findByText(initial.message); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); 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)" }); @@ -243,3 +250,100 @@ it("saves auto selection separately from scan movement and validates its delay", fireEvent.change(delay, { target: { value: "0.05" } }); expect(mocks.invoke).toHaveBeenCalledTimes(calls); }); + + +it("customises individual settings, restores defaults and returns focus without saving on navigation", async () => { + render(); + await screen.findByText(initial.message); + fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(screen.getByRole("heading", { name: "Keyboard" })).toHaveFocus(); + expect(screen.queryByRole("checkbox", { name: "Auto select" })).not.toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Word prediction" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Reverse" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: expect.objectContaining({ scanPreferences: expect.objectContaining({ keyboard: { direction: "reverse" }, menu: {} }) }) + })); + fireEvent.click(screen.getByRole("button", { name: "Use default for initial direction" })); + expect(screen.getByRole("button", { name: "Forward" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Back to scanning settings" })); + expect(screen.getByRole("button", { name: "Customise keyboard" })).toHaveFocus(); + fireEvent.click(screen.getByRole("button", { name: "Reverse" })); + fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); + expect(screen.getByRole("button", { name: "Reverse" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "Use default for initial direction" })).not.toBeInTheDocument(); +}); + +it("keeps rate values in manual mode and preserves keyboard settings when resetting overrides", async () => { + render(); + await screen.findByText(initial.message); + fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Word prediction" })); + fireEvent.change(screen.getByLabelText("Auto scan rate"), { target: { value: "500" } }); + fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" })); + expect(screen.getByLabelText("Auto scan rate")).toBeDisabled(); + expect(screen.getByLabelText("Auto scan rate")).toHaveValue("500"); + fireEvent.click(screen.getByRole("button", { name: "Use default for auto scan rate" })); + await waitFor(() => expect(screen.getByRole("group", { name: "Auto scan rate setting" })).toHaveFocus()); + expect(screen.getByLabelText("Auto scan rate")).toBeDisabled(); + expect(screen.getByLabelText("Auto scan rate")).toHaveValue("1000"); + fireEvent.click(screen.getByRole("button", { name: "Reset scanning overrides" })); + expect(screen.getByLabelText("Auto scan rate")).toBeEnabled(); + expect(screen.getByLabelText("Auto scan rate")).toHaveValue("1000"); + expect(screen.getByRole("checkbox", { name: "Word prediction" })).not.toBeChecked(); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: expect.objectContaining({ wordPrediction: false, scanPreferences: expect.objectContaining({ keyboard: {} }) }) + })); +}); + +it("restores overview scroll and leaves point-specific values intact on override reset", async () => { + render(); + await screen.findByText(initial.message); + document.documentElement.scrollTop = 380; + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); + expect(screen.queryByRole("button", { name: "One item at a time" })).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Word prediction" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Auto select" })); + fireEvent.click(screen.getByRole("button", { name: "Reverse" })); + fireEvent.click(screen.getByRole("button", { name: "Reset scanning overrides" })); + expect(screen.getByRole("button", { name: "Grid then line" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("checkbox", { name: "Auto select" })).toBeChecked(); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: expect.objectContaining({ mode: "grid", autoSelectEnabled: true, scanPreferences: expect.objectContaining({ point: {} }) }) + })); + const calls = mocks.invoke.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Back to scanning settings" })); + expect(document.documentElement.scrollTop).toBe(380); + expect(screen.getByRole("button", { name: "Customise point scanning" })).toHaveFocus(); + expect(mocks.invoke).toHaveBeenCalledTimes(calls); + document.documentElement.scrollTop = 0; +}); + +it("brings the area header into view with help expanded and restores the overview on Back", async () => { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView"); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView }); + try { + render(); + await screen.findByText(initial.message); + fireEvent.click(screen.getByText("How scanning works")); + expect(screen.getByText("How scanning works").closest("details")).toHaveAttribute("open"); + document.documentElement.scrollTop = 640; + fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); + const heading = screen.getByRole("heading", { name: "Keyboard" }); + expect(heading).toHaveFocus(); + expect(scrollIntoView).toHaveBeenCalledExactlyOnceWith({ block: "start", behavior: "instant" }); + expect(scrollIntoView.mock.contexts[0]).toBe(heading.closest("header")); + expect(document.documentElement.scrollTop).not.toBe(0); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Back to scanning settings" })); + expect(screen.getByRole("button", { name: "Customise keyboard" })).toHaveFocus(); + expect(document.documentElement.scrollTop).toBe(640); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + } finally { + document.documentElement.scrollTop = 0; + if (previous) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", previous); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } +}); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts new file mode 100644 index 00000000..519f1441 --- /dev/null +++ b/src/scanning/preferences.ts @@ -0,0 +1,28 @@ +import type { PointScanConfig, ScannerColor } from './useScanning'; +export type ScanArea = 'point' | 'menu' | 'keyboard'; +export type ScanOptions = { + automatic: boolean; + intervalMs: number; + direction: 'forward' | 'reverse'; + passLimit: number; + pattern: 'grouped' | 'linear'; + color: ScannerColor; + thickness: 'thin' | 'standard' | 'thick'; +}; +export type ScanPreferences = Pick & Record>; +export const defaultScanPreferences: ScanPreferences = { + direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', + point: {}, menu: {}, keyboard: {}, +}; +export const scanIntervals = [250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000]; +export function sharedOptions(config: PointScanConfig): ScanOptions { + const settings = config.scanPreferences ?? defaultScanPreferences; + return { automatic: config.automatic, intervalMs: config.blockIntervalMs, color: config.scannerColor, + direction: settings.direction, passLimit: settings.passLimit, pattern: settings.pattern, thickness: settings.thickness }; +} +export function areaOptions(config: PointScanConfig, area: ScanArea): ScanOptions { + const shared = sharedOptions(config); + const overrides = (config.scanPreferences ?? defaultScanPreferences)[area]; + return { ...shared, ...Object.fromEntries(Object.entries(overrides).filter(([, value]) => value != null)), + ...(area === 'point' ? { pattern: 'grouped' as const } : {}) }; +} diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 69a59bfa..365f4d8a 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; export type ScannerColor = "red" | "green" | "blue" | "yellow" | "white"; export type PointScanConfig = { + scanPreferences?: import("./preferences").ScanPreferences; wordPrediction: boolean; scannerColor: ScannerColor; mode: "line" | "grid"; diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx new file mode 100644 index 00000000..5dd6c96a --- /dev/null +++ b/src/settings/ScannerPreferences.tsx @@ -0,0 +1,166 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import type { ScanningController, ScannerColor, PointScanConfig } from '../scanning/useScanning'; +import { areaOptions, sharedOptions, defaultScanPreferences, scanIntervals, type ScanArea, type ScanOptions } from '../scanning/preferences'; +import { SettingGroup, Toggle, OptionGroup } from './controls'; + +export function ScannerPreferences({ controller }: { controller: ScanningController }) { + const { config, state, update } = controller; + const [area, setArea] = useState<'shared' | ScanArea>('shared'); + const heading = useRef(null); + const container = useRef(null); + const returnArea = useRef(null); + const overviewScroll = useRef(0); + const changedView = useRef(false); + const scrollContainer = () => { + const main = container.current?.closest('main'); + return main && main.scrollHeight > main.clientHeight ? main : document.scrollingElement ?? document.documentElement; + }; + useLayoutEffect(() => { + if (!changedView.current) return; + const scroller = scrollContainer(); + if (area === 'shared') { + container.current?.querySelector(`[data-area="${returnArea.current}"]`)?.focus({ preventScroll: true }); + if (scroller) scroller.scrollTop = overviewScroll.current; + } else { + heading.current?.focus({ preventScroll: true }); + heading.current?.closest('header')?.scrollIntoView?.({ block: 'start', behavior: 'instant' }); + } + }, [area]); + const openArea = (next: ScanArea) => { + overviewScroll.current = scrollContainer().scrollTop; + returnArea.current = next; + changedView.current = true; + setArea(next); + }; + const names = { point: 'Point scanning', menu: 'Menus', keyboard: 'Keyboard' }; + const disabled = !state?.supported; + const settings = config.scanPreferences ?? defaultScanPreferences; + const shared = sharedOptions(config); + const effective = area === 'shared' ? shared : areaOptions(config, area); + const change = (key: K, value: ScanOptions[K] | undefined) => { + if (area !== 'shared') { + const overrides = { ...settings[area], [key]: value }; + update('scanPreferences', { ...settings, [area]: overrides }); + } else if (key === 'automatic') update('automatic', value as boolean); + else if (key === 'intervalMs') update('blockIntervalMs', value as number); + else if (key === 'color') update('scannerColor', value as ScannerColor); + else update('scanPreferences', { ...settings, [key]: value }); + }; + const field = (key: keyof ScanOptions, label: string, children: (locked: boolean) => ReactNode) => { + const custom = area !== 'shared' && settings[area][key] != null; + return
+ {area !== 'shared' &&
{custom ? 'Custom' : 'Default'} + {custom && } +
} + {children(disabled)} +
; + }; + return
+ {area === 'shared' &&
+

Customise an area

Give an area different settings, or keep using your defaults.

+
{(['point', 'menu', 'keyboard'] as const).map(key => { + const options = areaOptions(config, key); + const count = Object.entries(settings[key]).filter(([name, value]) => value != null && !(key === 'point' && name === 'pattern')).length; + return