From f5f5c64f65e4c652e021c41db1ac7b8617f9b1e4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:17:00 +0100 Subject: [PATCH 1/2] Add after-selection scan preferences to the settings model Next scan and Start from have a shared value and an override for each scanner. Standard keeps what each scanner does today. A saved wait after typing becomes the keyboard's choice, and a value this version does not know falls back alone. Closes #930 Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_scan.rs | 43 +++++++++++++ src-tauri/src/scan_preferences.rs | 103 +++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 87f97782..e1fe77b0 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -103,6 +103,13 @@ impl Config { .resolved(crate::scan_preferences::Area::Keyboard) .automatic; } + // Saved before the choice existed for every scanner. + if area == crate::scan_preferences::Area::Keyboard + && self.keyboard_wait_after_typing + && resolved.next_scan == crate::scan_preferences::NextScan::Standard + { + resolved.next_scan = crate::scan_preferences::NextScan::Wait; + } resolved } pub fn point(&self) -> PointSettings { @@ -616,6 +623,42 @@ mod tests { assert!(!restored.word_prediction); } #[test] + fn saved_wait_after_typing_becomes_the_keyboard_choice() { + use crate::scan_preferences::{Area, NextScan}; + let saved: Config = serde_json::from_str(r#"{"keyboardWaitAfterTyping":true}"#).unwrap(); + assert_eq!(saved.resolved(Area::Keyboard).next_scan, NextScan::Wait); + for area in [Area::Point, Area::Menu, Area::Mouse] { + assert_eq!(saved.resolved(area).next_scan, NextScan::Standard); + } + // A choice made since then is kept. + let chosen: Config = serde_json::from_str( + r#"{"keyboardWaitAfterTyping":true,"scanPreferences":{"keyboard":{"nextScan":"automatic"}}}"#, + ) + .unwrap(); + assert_eq!( + chosen.resolved(Area::Keyboard).next_scan, + NextScan::Automatic + ); + let shared: Config = serde_json::from_str( + r#"{"keyboardWaitAfterTyping":true,"scanPreferences":{"nextScan":"automatic"}}"#, + ) + .unwrap(); + assert_eq!( + shared.resolved(Area::Keyboard).next_scan, + NextScan::Automatic + ); + } + #[test] + fn an_unknown_choice_keeps_every_other_scan_setting() { + let saved: Config = serde_json::from_str( + r#"{"gridSize":7,"scanPreferences":{"passLimit":5,"nextScan":"sometimes"}}"#, + ) + .unwrap(); + saved.validate().unwrap(); + assert_eq!(saved.grid_size, 7); + assert_eq!(saved.scan_preferences.pass_limit, 5); + } + #[test] fn enhanced_prediction_defaults_off_for_saved_settings_and_round_trips() { let c: Config = serde_json::from_str(r#"{"wordPrediction":true}"#).unwrap(); assert!(!c.enhanced_word_prediction); diff --git a/src-tauri/src/scan_preferences.rs b/src-tauri/src/scan_preferences.rs index 20cea2b8..7b052f2d 100644 --- a/src-tauri/src/scan_preferences.rs +++ b/src-tauri/src/scan_preferences.rs @@ -32,6 +32,27 @@ impl Thickness { } } } +/// Whether scanning moves on by itself after a selection that performed +/// an action. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NextScan { + /// What the scanner has always done. + #[default] + Standard, + Automatic, + Wait, +} +/// Where scanning starts after a selection that performed an action. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartFrom { + /// What the scanner has always done. + #[default] + Standard, + Beginning, + Selection, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Area { Point, @@ -49,6 +70,8 @@ pub struct Overrides { pub pattern: Option, pub color: Option, pub thickness: Option, + pub next_scan: Option, + pub start_from: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -57,6 +80,8 @@ pub struct Preferences { pub pass_limit: usize, pub pattern: Pattern, pub thickness: Thickness, + pub next_scan: NextScan, + pub start_from: StartFrom, pub point: Overrides, pub menu: Overrides, pub keyboard: Overrides, @@ -69,6 +94,8 @@ impl Default for Preferences { pass_limit: 3, pattern: Pattern::Grouped, thickness: Thickness::Standard, + next_scan: NextScan::Standard, + start_from: StartFrom::Standard, point: Default::default(), menu: Default::default(), keyboard: Default::default(), @@ -85,6 +112,8 @@ pub struct Resolved { pub pattern: Pattern, pub color: ScannerColor, pub thickness: Thickness, + pub next_scan: NextScan, + pub start_from: StartFrom, } impl Default for Resolved { fn default() -> Self { @@ -134,13 +163,22 @@ impl Preferences { }, color: local.color.unwrap_or(color), thickness: local.thickness.unwrap_or(self.thickness), + next_scan: local.next_scan.unwrap_or(self.next_scan), + start_from: local.start_from.unwrap_or(self.start_from), } } } pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result { - let value = serde_json::Value::deserialize(deserializer)?; + let mut value = serde_json::Value::deserialize(deserializer)?; + // A choice this version does not know must not cost the other settings. + forget_unknown_choices(&mut value); + for area in ["point", "menu", "keyboard", "mouse"] { + if let Some(local) = value.get_mut(area) { + forget_unknown_choices(local); + } + } let mut preferences: Preferences = serde_json::from_value(value).unwrap_or_default(); if !valid_passes(preferences.pass_limit) { preferences.pass_limit = 3; @@ -156,10 +194,73 @@ pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( } Ok(preferences) } +fn forget_unknown_choices(value: &mut serde_json::Value) { + let Some(fields) = value.as_object_mut() else { + return; + }; + fields.retain(|name, choice| match name.as_str() { + "nextScan" => choice.is_null() || NextScan::deserialize(&*choice).is_ok(), + "startFrom" => choice.is_null() || StartFrom::deserialize(&*choice).is_ok(), + _ => true, + }); +} #[cfg(test)] mod tests { use super::*; + fn read(value: serde_json::Value) -> Preferences { + deserialize_preferences(value).unwrap() + } + #[test] + fn after_selection_choices_default_to_standard_and_round_trip() { + let saved = read(serde_json::json!({"direction":"reverse"})); + assert_eq!(saved.next_scan, NextScan::Standard); + assert_eq!(saved.start_from, StartFrom::Standard); + for area in [Area::Point, Area::Menu, Area::Keyboard, Area::Mouse] { + let resolved = saved.resolve(area, true, 1000, ScannerColor::Blue); + assert_eq!(resolved.next_scan, NextScan::Standard); + assert_eq!(resolved.start_from, StartFrom::Standard); + } + let mut chosen = Preferences { + next_scan: NextScan::Wait, + start_from: StartFrom::Selection, + ..Default::default() + }; + chosen.menu.next_scan = Some(NextScan::Automatic); + chosen.mouse.start_from = Some(StartFrom::Beginning); + let stored = serde_json::to_value(&chosen).unwrap(); + assert_eq!(stored["nextScan"], "wait"); + assert_eq!(stored["startFrom"], "selection"); + assert_eq!(stored["menu"]["nextScan"], "automatic"); + assert_eq!(stored["mouse"]["startFrom"], "beginning"); + assert_eq!(read(stored), chosen); + let menu = chosen.resolve(Area::Menu, true, 1000, ScannerColor::Blue); + assert_eq!(menu.next_scan, NextScan::Automatic); + assert_eq!(menu.start_from, StartFrom::Selection); + let mouse = chosen.resolve(Area::Mouse, true, 1000, ScannerColor::Blue); + assert_eq!(mouse.next_scan, NextScan::Wait); + assert_eq!(mouse.start_from, StartFrom::Beginning); + } + #[test] + fn an_unknown_choice_falls_back_alone() { + let saved = read(serde_json::json!({ + "direction": "reverse", + "passLimit": 5, + "nextScan": "sometimes", + "startFrom": 7, + "keyboard": {"intervalMs": 250, "nextScan": "later", "startFrom": "selection"}, + "menu": {"nextScan": "wait", "startFrom": null}, + })); + assert_eq!(saved.direction, Direction::Reverse); + assert_eq!(saved.pass_limit, 5); + assert_eq!(saved.next_scan, NextScan::Standard); + assert_eq!(saved.start_from, StartFrom::Standard); + assert_eq!(saved.keyboard.interval_ms, Some(250)); + assert_eq!(saved.keyboard.next_scan, None); + assert_eq!(saved.keyboard.start_from, Some(StartFrom::Selection)); + assert_eq!(saved.menu.next_scan, Some(NextScan::Wait)); + assert_eq!(saved.menu.start_from, None); + } #[test] fn areas_inherit_and_override_independently() { let mut settings = Preferences::default(); From 41d0c5d22f87010aa72c8d55d7bdd0f5761b8799 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 09:20:00 +0100 Subject: [PATCH 2/2] Keep a null shared choice from resetting scan preferences Review follow-up: a null shared value is dropped like an unknown one, and the keyboard's own standard choice now stands over a saved wait after typing. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/point_scan.rs | 29 +++++++++++++++++++++++++++-- src-tauri/src/scan_preferences.rs | 16 ++++++++++------ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index e1fe77b0..9d48bc25 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -103,9 +103,12 @@ impl Config { .resolved(crate::scan_preferences::Area::Keyboard) .automatic; } - // Saved before the choice existed for every scanner. + // Saved before the choice existed for every scanner. It stands + // until the keyboard is given a choice of its own, or every scanner + // is given one other than standard. if area == crate::scan_preferences::Area::Keyboard && self.keyboard_wait_after_typing + && self.scan_preferences.keyboard.next_scan.is_none() && resolved.next_scan == crate::scan_preferences::NextScan::Standard { resolved.next_scan = crate::scan_preferences::NextScan::Wait; @@ -624,9 +627,21 @@ mod tests { } #[test] fn saved_wait_after_typing_becomes_the_keyboard_choice() { - use crate::scan_preferences::{Area, NextScan}; + use crate::scan_preferences::{Area, NextScan, StartFrom}; let saved: Config = serde_json::from_str(r#"{"keyboardWaitAfterTyping":true}"#).unwrap(); assert_eq!(saved.resolved(Area::Keyboard).next_scan, NextScan::Wait); + assert_eq!( + saved.resolved(Area::Keyboard).start_from, + StartFrom::Standard + ); + let standard: Config = serde_json::from_str( + r#"{"keyboardWaitAfterTyping":true,"scanPreferences":{"keyboard":{"nextScan":"standard"}}}"#, + ) + .unwrap(); + assert_eq!( + standard.resolved(Area::Keyboard).next_scan, + NextScan::Standard + ); for area in [Area::Point, Area::Menu, Area::Mouse] { assert_eq!(saved.resolved(area).next_scan, NextScan::Standard); } @@ -659,6 +674,16 @@ mod tests { assert_eq!(saved.scan_preferences.pass_limit, 5); } #[test] + fn after_selection_choices_round_trip_through_the_saved_file() { + use crate::scan_preferences::{NextScan, StartFrom}; + let mut config = Config::default(); + config.scan_preferences.next_scan = NextScan::Automatic; + config.scan_preferences.point.start_from = Some(StartFrom::Selection); + let restored: Config = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert_eq!(restored, config); + } + #[test] fn enhanced_prediction_defaults_off_for_saved_settings_and_round_trips() { let c: Config = serde_json::from_str(r#"{"wordPrediction":true}"#).unwrap(); assert!(!c.enhanced_word_prediction); diff --git a/src-tauri/src/scan_preferences.rs b/src-tauri/src/scan_preferences.rs index 7b052f2d..c37895a9 100644 --- a/src-tauri/src/scan_preferences.rs +++ b/src-tauri/src/scan_preferences.rs @@ -173,10 +173,10 @@ pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( ) -> Result { let mut value = serde_json::Value::deserialize(deserializer)?; // A choice this version does not know must not cost the other settings. - forget_unknown_choices(&mut value); + forget_unknown_choices(&mut value, false); for area in ["point", "menu", "keyboard", "mouse"] { if let Some(local) = value.get_mut(area) { - forget_unknown_choices(local); + forget_unknown_choices(local, true); } } let mut preferences: Preferences = serde_json::from_value(value).unwrap_or_default(); @@ -194,13 +194,14 @@ pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( } Ok(preferences) } -fn forget_unknown_choices(value: &mut serde_json::Value) { +/// Only a scanner's own choice may be absent, which is saved as null. +fn forget_unknown_choices(value: &mut serde_json::Value, optional: bool) { let Some(fields) = value.as_object_mut() else { return; }; fields.retain(|name, choice| match name.as_str() { - "nextScan" => choice.is_null() || NextScan::deserialize(&*choice).is_ok(), - "startFrom" => choice.is_null() || StartFrom::deserialize(&*choice).is_ok(), + "nextScan" => (optional && choice.is_null()) || NextScan::deserialize(&*choice).is_ok(), + "startFrom" => (optional && choice.is_null()) || StartFrom::deserialize(&*choice).is_ok(), _ => true, }); } @@ -246,7 +247,7 @@ mod tests { let saved = read(serde_json::json!({ "direction": "reverse", "passLimit": 5, - "nextScan": "sometimes", + "nextScan": null, "startFrom": 7, "keyboard": {"intervalMs": 250, "nextScan": "later", "startFrom": "selection"}, "menu": {"nextScan": "wait", "startFrom": null}, @@ -260,6 +261,9 @@ mod tests { assert_eq!(saved.keyboard.start_from, Some(StartFrom::Selection)); assert_eq!(saved.menu.next_scan, Some(NextScan::Wait)); assert_eq!(saved.menu.start_from, None); + let saved = read(serde_json::json!({"passLimit": 5, "nextScan": "sometimes"})); + assert_eq!(saved.pass_limit, 5); + assert_eq!(saved.next_scan, NextScan::Standard); } #[test] fn areas_inherit_and_override_independently() {