From 42c404772c802ef3e2046385f16dfce6c74734b8 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:23:08 +0100 Subject: [PATCH 01/12] Add shared scanner preferences and per-area overrides Resolve compatible settings for point, menu and keyboard scanning, including timing, direction, passes, traversal and highlights. Preserve switch ownership and safe cancellation, and cover override persistence and reverse prediction traversal. --- src-tauri/src/lib.rs | 1 + src-tauri/src/point_scan.rs | 139 ++++++++++++++++++-- src-tauri/src/point_workflow.rs | 97 +++++++++++--- src-tauri/src/remote_scan.rs | 3 +- src-tauri/src/scan_items.rs | 148 ++++++++++++++++++++- src-tauri/src/scan_keyboard.rs | 102 +++++++++----- src-tauri/src/scan_menu.rs | 74 +++++++---- src-tauri/src/scan_preferences.rs | 197 ++++++++++++++++++++++++++++ src-tauri/src/scan_tile.rs | 28 ++-- src-tauri/src/scan_tree.rs | 4 + src-tauri/src/scanning.rs | 14 +- src/PointScan.test.tsx | 25 ++++ src/scanning/preferences.ts | 28 ++++ src/scanning/useScanning.ts | 1 + src/settings/ScannerPreferences.tsx | 54 ++++++++ src/settings/ScanningSection.tsx | 50 +------ src/settings/settings.test.tsx | 2 +- src/styles.css | 5 + 18 files changed, 808 insertions(+), 164 deletions(-) create mode 100644 src-tauri/src/scan_preferences.rs create mode 100644 src/scanning/preferences.ts create mode 100644 src/settings/ScannerPreferences.tsx 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..b133acdb 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,44 @@ 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, + crate::scan_preferences::Area::App, + ] + .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 +103,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 +189,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 +226,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 +356,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 +504,7 @@ impl Technique for Engine { self.phase } fn exhausted(&self) -> bool { - self.cycles >= MAX_SCAN_CYCLES + self.config.scan.exhausted(self.cycles) } } @@ -892,6 +943,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 +985,62 @@ 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, Area::App] { + 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 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..7d438033 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,33 +225,26 @@ 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(); + self.skip_disabled(false); return; } if self.prediction_row_active() && self.predictions.is_some() { 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(); + self.skip_disabled(false); } } else { self.predictions = batch; @@ -268,18 +265,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 +293,14 @@ 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) { + let moved = self.scan.advance(ms, period); + if moved { self.prefer_predictions = false; } - self.skip_disabled(); + self.skip_disabled(moved); } pub fn handle(&mut self, action: Action) -> Option { if self.scan.pending() { @@ -316,7 +317,7 @@ impl Keyboard { 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 +493,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 +514,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 +523,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, @@ -541,7 +538,7 @@ impl Keyboard { selected: !self.scan.suspended && !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; } @@ -565,11 +562,12 @@ impl Keyboard { 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 +592,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 +818,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 +848,37 @@ 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()); + } + } } 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..a1688093 --- /dev/null +++ b/src-tauri/src/scan_preferences.rs @@ -0,0 +1,197 @@ +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, + App, +} +#[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, + pub app: 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(), + app: 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, + Area::App => self.app, + }; + 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, + &mut preferences.app, + ] { + 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, Area::App] { + 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/PointScan.test.tsx b/src/PointScan.test.tsx index c82aa237..8f49b73e 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -243,3 +243,28 @@ 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("persists area overrides and restores inheritance without changing other areas", async () => { + render(); + await screen.findByText(initial.message); + const area = screen.getByRole("combobox", { name: "Customise scanning for" }); + fireEvent.change(area, { target: { value: "keyboard" } }); + expect(screen.getByRole("button", { name: "Reverse" })).toBeDisabled(); + fireEvent.click(screen.getByRole("checkbox", { name: "Use shared initial direction" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Reverse" })).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.change(area, { target: { value: "menu" } }); + expect(screen.getByRole("button", { name: "Forward" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.change(area, { target: { value: "keyboard" } }); + expect(screen.getByRole("button", { name: "Reverse" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Use shared settings for this area" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Forward" })).toBeDisabled()); + expect(screen.getByRole("button", { name: "Forward" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.change(area, { target: { value: "point" } }); + expect(screen.queryByRole("button", { name: "One item at a time" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Line only" })).toBeEnabled(); +}); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts new file mode 100644 index 00000000..c29b002f --- /dev/null +++ b/src/scanning/preferences.ts @@ -0,0 +1,28 @@ +import type { PointScanConfig, ScannerColor } from './useScanning'; +export type ScanArea = 'point' | 'menu' | 'keyboard' | 'app'; +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: {}, app: {}, +}; +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..aa82f458 --- /dev/null +++ b/src/settings/ScannerPreferences.tsx @@ -0,0 +1,54 @@ +import { useState, type ReactNode } from 'react'; +import type { ScanningController, ScannerColor } from '../scanning/useScanning'; +import { areaOptions, sharedOptions, defaultScanPreferences, scanIntervals, type ScanArea, type ScanOptions } from '../scanning/preferences'; +import { SettingGroup, Toggle, OptionGroup, secondsOptions } from './controls'; + +export function ScannerPreferences({ controller }: { controller: ScanningController }) { + const { config, state, update } = controller; + const [area, setArea] = useState<'shared' | ScanArea>('shared'); + 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 inherited = area !== 'shared' && settings[area][key] == null; + return
+ {area !== 'shared' && change(key, inherit ? undefined : effective[key])} />} + {children(disabled || inherited)} +
; + }; + return + + {area !== 'shared' &&

Checked “Use shared” controls follow your shared defaults. Uncheck one to choose a value for this area.

} + {field('automatic', 'Automatic scanning', locked => change('automatic', value)} />)} + {field('intervalMs', 'Auto scan rate', locked => legend="Auto scan rate" disabled={locked} value={effective.intervalMs} options={secondsOptions(scanIntervals)} onChange={value => change('intervalMs', value)} + note={{ summary: 'How long each grid row, grid cell, or action-menu item stays highlighted before scanning moves on automatically. Shorter times scan faster; longer times give you more time to select. Line speed controls how fast the scanning lines move.' }} />)} + {field('direction', 'Initial direction', locked => legend="Initial direction" disabled={locked} value={effective.direction} options={[{ value: 'forward', label: 'Forward' }, { value: 'reverse', label: 'Reverse' }]} onChange={value => change('direction', value)} />)} + {field('passLimit', 'Pass limit', locked => legend="Pass limit" disabled={locked} value={effective.passLimit} options={[1, 2, 3, 5, 0].map(value => ({ value, label: value ? `${value} ${value === 1 ? 'pass' : 'passes'}` : 'Unlimited' }))} onChange={value => change('passLimit', value)} + note={{ summary: 'After this many automatic passes, scanning waits for Select. Unlimited keeps scanning until you pause or stop.' }} />)} + {area !== 'point' && field('pattern', 'Item scanning', locked => legend="Item scanning" disabled={locked} value={effective.pattern} options={[{ value: 'grouped', label: 'Groups, then items' }, { value: 'linear', label: 'One item at a time' }]} onChange={value => change('pattern', value)} + note={{ summary: 'Grouped scanning chooses a row or group before an item. Linear scanning visits each item directly. Point scanning uses its own line and grid modes.' }} />)} + {field('color', 'Scanner colour', locked =>
Scanner colour
+ {(['red', 'green', 'blue', 'yellow', 'white'] as const).map(colour => )} +
)} + {field('thickness', 'Highlight thickness', locked => legend="Highlight thickness" disabled={locked} value={effective.thickness} options={(['thin', 'standard', 'thick'] as const).map(value => ({ value, label: value[0].toUpperCase() + value.slice(1) }))} onChange={value => change('thickness', value)} />)} +
+ Selected area +
+ {area !== 'shared' && } +

Assign switch actions in the Switches page. All actions run on release. Holding a switch freezes movement. Manual scanning needs Select, Next and Previous. After clicking, or reaching the pass limit, use Select to start again.

+

Saving changes cancels the current scan and waits for Select.

+
; +} diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 7c077320..8a2b2f3e 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -4,7 +4,9 @@ import { validSwitches, } from "../scanning/useScanning"; -import { SettingGroup, Toggle, OptionGroup, secondsOptions } from "./controls"; +import { SettingGroup, Toggle, OptionGroup } from "./controls"; + +import { ScannerPreferences } from "./ScannerPreferences"; const phases = { keyboard: "Choose a keyboard row, then a key", @@ -91,33 +93,7 @@ export function ScanningSection({ )} - - update("automatic", value)} - /> - - - legend="Auto scan rate" - disabled={disabled} - value={config.blockIntervalMs} - onChange={(value) => update("blockIntervalMs", value)} - options={secondsOptions([ - 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000, - ])} - note={{ - summary: "How long each grid row, grid cell, or action-menu item stays highlighted before scanning moves on automatically. Shorter times scan faster; longer times give you more time to select. Line speed controls how fast the scanning lines move.", - }} - /> - -

Assign switch actions in the Switches page. All actions run on release. Holding a switch freezes movement. After clicking, or after three passes without a selection, use Select to start again.

- -
+ update("wordPrediction", value)} /> @@ -129,24 +105,6 @@ export function ScanningSection({ } - -
- Scanner colour -
- {(["red", "green", "blue", "yellow", "white"] as const).map((colour) => ( - - ))} -
-
-
- Selected area -
-
- { expect(screen.queryByRole("button",{name:"Point scan"})).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button",{name:"Scanning"})); expect(screen.getByRole("heading", { name: "Scanning", level: 1 })).toBeInTheDocument(); - expect(screen.getByRole("heading",{name:"Scan movement"})).toBeInTheDocument(); + expect(screen.getByRole("heading",{name:"Scanning defaults and overrides"})).toBeInTheDocument(); expect(screen.getByRole("heading",{name:"Point scan"})).toBeInTheDocument(); }); diff --git a/src/styles.css b/src/styles.css index 6760a4a0..adea5cb5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -385,3 +385,8 @@ button:disabled { cursor: default; opacity: 0.55; } .scanner-sample.yellow { --scanner-colour: #ffd166; } .scanner-sample.white { --scanner-colour: #ffffff; } .scanner-sample-selection { display: flex; align-items: center; justify-content: center; height: 100%; border: 3px solid var(--scanner-colour); background: color-mix(in srgb, var(--scanner-colour) 25%, transparent); color: white; } + +.scanner-sample-selection.thin { border-width: 1px; } +.scanner-sample-selection.standard { border-width: 2px; } +.scanner-sample-selection.thick { border-width: 4px; } +.scanner-preference + .scanner-preference { margin-top: 20px; } From 6918aecd388316888e088ad767a22346f1928eca Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:31:10 +0100 Subject: [PATCH 02/12] Show resolved point scanning mode on the home screen --- src/App.test.tsx | 4 +++- src/App.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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" && }
; From e833a0001beb6ec3d4d692062a6e35a51e6c7d8e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:32:54 +0100 Subject: [PATCH 03/12] Document effective scanner options and provider integration --- docs/scanner-architecture.md | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/scanner-architecture.md b/docs/scanner-architecture.md index 1b32a899..d632c5c4 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,28 @@ 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 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. + +## Following stage + +Issue #802 adds a main-window React content adapter and foreground/modal ownership, +using the app-area preferences already stored here. Individual screens must not +implement traversal or timers. Preserve input generation checks, cleanup, native +focus, point countdown cancellation and Remote behaviour. From ce56f4784bb31a66bcf774d9a7846a447dec73d0 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:35:26 +0100 Subject: [PATCH 04/12] Keep keyboard resume and error instructions above prediction warnings --- src-tauri/src/scan_keyboard.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 7d438033..739fc272 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -548,14 +548,14 @@ 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.prediction_failed { + "Predictions unavailable · Keyboard ready".to_owned() } else if row_scan { format!("{} · Select a row", page) } else { @@ -881,4 +881,27 @@ mod tests { 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.label.contains("Select to resume"))); + k.failed(); + let frame = k.frame(screen, 1.0, ScannerColor::Blue); + assert!(frame + .tiles + .iter() + .any(|tile| tile.label.contains("Select to try again"))); + } } From fbd0e639c52534446ccb488fb9a9a72385a9111e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:35:58 +0100 Subject: [PATCH 05/12] Correct keyboard feedback regression assertion field --- src-tauri/src/scan_keyboard.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 739fc272..c423a733 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -896,12 +896,12 @@ mod tests { assert!(frame .tiles .iter() - .any(|tile| tile.label.contains("Select to resume"))); + .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.label.contains("Select to try again"))); + .any(|tile| tile.text.contains("Select to try again"))); } } From 223f9d0400b4e367681ee359eece82796cfa0326 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:52:42 +0100 Subject: [PATCH 06/12] Preserve reverse scan origin when predictions arrive --- src-tauri/src/scan_keyboard.rs | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index c423a733..8b1a6c0f 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -249,6 +249,7 @@ impl Keyboard { } else { self.predictions = batch; if self.prefer_predictions + && self.scan.options.direction == crate::scan_preferences::Direction::Forward && self .predictions .as_ref() @@ -904,4 +905,44 @@ mod tests { .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()); + } + } } From 931f7b4d9b3e0273416d0df7c04112618b77ad62 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 11:59:26 +0100 Subject: [PATCH 07/12] Defer stale prediction navigation until permitted scan movement --- src-tauri/src/scan_keyboard.rs | 87 +++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 8b1a6c0f..922a0e3a 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -237,14 +237,12 @@ impl Keyboard { if batch.is_none() { self.predictions = None; self.queued_predictions = None; - self.skip_disabled(false); 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(false); } } else { self.predictions = batch; @@ -297,11 +295,10 @@ impl Keyboard { self.skip_disabled(false); } pub fn advance(&mut self, ms: u64, period: u64) { - let moved = self.scan.advance(ms, period); - if moved { + if self.scan.advance(ms, period) { self.prefer_predictions = false; + self.skip_disabled(true); } - self.skip_disabled(moved); } pub fn handle(&mut self, action: Action) -> Option { if self.scan.pending() { @@ -315,6 +312,11 @@ 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); } @@ -537,6 +539,7 @@ impl Keyboard { }, scale, selected: !self.scan.suspended + && !self.disabled() && !self.scan.nav.escaping() && r == active_row && active_column.is_none_or(|column| c == column), @@ -555,6 +558,8 @@ impl Keyboard { "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 { @@ -945,4 +950,74 @@ mod tests { 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()); + } + } + } + } + } } From 4c69157c90362e3ec0361e367bec7240509f7af5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 12:32:51 +0100 Subject: [PATCH 08/12] Remove app-screen scanner preferences from scope --- docs/scanner-architecture.md | 9 +-------- src-tauri/src/point_scan.rs | 22 ++++++++++++++++++++-- src-tauri/src/scan_preferences.rs | 7 +------ src/scanning/preferences.ts | 4 ++-- src/settings/ScannerPreferences.tsx | 2 +- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/scanner-architecture.md b/docs/scanner-architecture.md index d632c5c4..718efb56 100644 --- a/docs/scanner-architecture.md +++ b/docs/scanner-architecture.md @@ -59,7 +59,7 @@ including reordered content, cancellation, exactly-once completion and timing. ## Settings `scan_preferences::Preferences` resolves shared defaults and optional per-area -values into `Resolved` options. The existing top-level automatic, interval and +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. @@ -74,10 +74,3 @@ 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. - -## Following stage - -Issue #802 adds a main-window React content adapter and foreground/modal ownership, -using the app-area preferences already stored here. Individual screens must not -implement traversal or timers. Preserve input generation checks, cleanup, native -focus, point countdown cancellation and Remote behaviour. diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index b133acdb..f9d8f2e9 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -58,7 +58,6 @@ impl Config { crate::scan_preferences::Area::Point, crate::scan_preferences::Area::Menu, crate::scan_preferences::Area::Keyboard, - crate::scan_preferences::Area::App, ] .into_iter() .all(|area| self.resolved(area).automatic), @@ -990,7 +989,7 @@ mod tests { 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, Area::App] { + for area in [Area::Point, Area::Menu, Area::Keyboard] { let resolved = config.resolved(area); assert!(!resolved.automatic); assert_eq!(resolved.interval_ms, 1500); @@ -1009,6 +1008,25 @@ mod tests { 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 [ diff --git a/src-tauri/src/scan_preferences.rs b/src-tauri/src/scan_preferences.rs index a1688093..a6dd65c4 100644 --- a/src-tauri/src/scan_preferences.rs +++ b/src-tauri/src/scan_preferences.rs @@ -37,7 +37,6 @@ pub enum Area { Point, Menu, Keyboard, - App, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase", default)] @@ -60,7 +59,6 @@ pub struct Preferences { pub point: Overrides, pub menu: Overrides, pub keyboard: Overrides, - pub app: Overrides, } impl Default for Preferences { fn default() -> Self { @@ -72,7 +70,6 @@ impl Default for Preferences { point: Default::default(), menu: Default::default(), keyboard: Default::default(), - app: Default::default(), } } } @@ -111,7 +108,6 @@ impl Preferences { Area::Point => self.point, Area::Menu => self.menu, Area::Keyboard => self.keyboard, - Area::App => self.app, }; Resolved { automatic: local.automatic.unwrap_or(automatic), @@ -149,7 +145,6 @@ pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( &mut preferences.point, &mut preferences.menu, &mut preferences.keyboard, - &mut preferences.app, ] { local.interval_ms = local.interval_ms.filter(|v| (250..=5000).contains(v)); local.pass_limit = local.pass_limit.filter(|v| valid_passes(*v)); @@ -172,7 +167,7 @@ mod tests { assert_eq!(keyboard.interval_ms, 250); assert!(!keyboard.exhausted(10000)); assert_eq!(keyboard.color, ScannerColor::Red); - for area in [Area::Point, Area::Menu, Area::App] { + 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); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index c29b002f..519f1441 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -1,5 +1,5 @@ import type { PointScanConfig, ScannerColor } from './useScanning'; -export type ScanArea = 'point' | 'menu' | 'keyboard' | 'app'; +export type ScanArea = 'point' | 'menu' | 'keyboard'; export type ScanOptions = { automatic: boolean; intervalMs: number; @@ -12,7 +12,7 @@ export type ScanOptions = { export type ScanPreferences = Pick & Record>; export const defaultScanPreferences: ScanPreferences = { direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', - point: {}, menu: {}, keyboard: {}, app: {}, + point: {}, menu: {}, keyboard: {}, }; export const scanIntervals = [250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000]; export function sharedOptions(config: PointScanConfig): ScanOptions { diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index aa82f458..2829a7e7 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -29,7 +29,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl }; return {area !== 'shared' &&

Checked “Use shared” controls follow your shared defaults. Uncheck one to choose a value for this area.

} {field('automatic', 'Automatic scanning', locked => change('automatic', value)} />)} From 3d876dd880920754050f92a06028a5282d7bd887 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 12:57:52 +0100 Subject: [PATCH 09/12] Organise scanner settings around defaults and area cards --- src/PointScan.test.tsx | 76 +++++++++++--- src/settings/ScannerPreferences.tsx | 148 ++++++++++++++++++++++---- src/settings/ScanningSection.tsx | 157 ++++------------------------ src/settings/settings.test.tsx | 4 +- src/styles.css | 47 +++++++++ 5 files changed, 260 insertions(+), 172 deletions(-) diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 8f49b73e..55a1028a 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)" }); @@ -245,26 +252,67 @@ it("saves auto selection separately from scan movement and validates its delay", }); -it("persists area overrides and restores inheritance without changing other areas", async () => { +it("customises individual settings, restores defaults and returns focus without saving on navigation", async () => { render(); await screen.findByText(initial.message); - const area = screen.getByRole("combobox", { name: "Customise scanning for" }); - fireEvent.change(area, { target: { value: "keyboard" } }); - expect(screen.getByRole("button", { name: "Reverse" })).toBeDisabled(); - fireEvent.click(screen.getByRole("checkbox", { name: "Use shared initial direction" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Reverse" })).toBeEnabled()); + 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.change(area, { target: { value: "menu" } }); + fireEvent.click(screen.getByRole("button", { name: "Use default for initial direction" })); expect(screen.getByRole("button", { name: "Forward" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.change(area, { target: { value: "keyboard" } }); + 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"); - fireEvent.click(screen.getByRole("button", { name: "Use shared settings for this area" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Forward" })).toBeDisabled()); - expect(screen.getByRole("button", { name: "Forward" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.change(area, { target: { value: "point" } }); + 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: "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(document.documentElement.scrollTop).toBe(0); expect(screen.queryByRole("button", { name: "One item at a time" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Line only" })).toBeEnabled(); + 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; }); diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 2829a7e7..518831dc 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -1,11 +1,38 @@ -import { useState, type ReactNode } from 'react'; -import type { ScanningController, ScannerColor } from '../scanning/useScanning'; +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, secondsOptions } from './controls'; +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 }); + if (scroller) scroller.scrollTop = 0; + } + }, [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); @@ -20,26 +47,111 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl else update('scanPreferences', { ...settings, [key]: value }); }; const field = (key: keyof ScanOptions, label: string, children: (locked: boolean) => ReactNode) => { - const inherited = area !== 'shared' && settings[area][key] == null; + const custom = area !== 'shared' && settings[area][key] != null; return
- {area !== 'shared' && change(key, inherit ? undefined : effective[key])} />} - {children(disabled || inherited)} + {area !== 'shared' &&
{custom ? 'Custom' : 'Default'} + {custom && } +
} + {children(disabled)}
; }; - return - - {area !== 'shared' &&

Checked “Use shared” controls follow your shared defaults. Uncheck one to choose a value for this area.

} + 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