From 870745eed51dc5acc93bfe203e635754de11abd9 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 18:24:47 +0100 Subject: [PATCH] Adjust auto scan intervals in 0.1-second steps --- src-tauri/src/point_scan.rs | 33 ++++++++++++++++++++++++++++- src-tauri/src/scan_menu.rs | 28 ++++++++++-------------- src-tauri/src/scan_preferences.rs | 4 ++-- src/PointScan.test.tsx | 31 +++++++++++++++++++++------ src/scanning/preferences.ts | 1 - src/settings/ScannerPreferences.tsx | 10 +++++---- src/styles.css | 4 ++++ 7 files changed, 79 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index c1599365..273f092a 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -122,7 +122,7 @@ impl PointSettings { fn validate(&self) -> Result<(), String> { if self.speed > 4 || !(2..=10).contains(&self.grid_size) - || !(250..=5000).contains(&self.block_interval_ms) + || !(100..=10000).contains(&self.block_interval_ms) || !(100..=100_000).contains(&self.auto_select_delay_ms) || !self.auto_select_delay_ms.is_multiple_of(100) { @@ -513,6 +513,37 @@ impl Technique for Engine { #[cfg(test)] mod tests { + #[test] + fn auto_scan_interval_bounds_and_legacy_values_roundtrip() { + for interval in [100, 200, 250, 750, 1000, 10000] { + let mut config = super::Config { + block_interval_ms: interval, + ..Default::default() + }; + config.scan_preferences.point.interval_ms = Some(interval); + config.scan_preferences.menu.interval_ms = Some(interval); + config.scan_preferences.keyboard.interval_ms = Some(interval); + config.validate().unwrap(); + let restored: super::Config = + serde_json::from_str(&serde_json::to_string(&config).unwrap()).unwrap(); + for area in [ + crate::scan_preferences::Area::Point, + crate::scan_preferences::Area::Menu, + crate::scan_preferences::Area::Keyboard, + ] { + assert_eq!(restored.resolved(area).interval_ms, interval); + } + } + for interval in [0, 99, 10001, u64::MAX] { + assert!(super::Config { + block_interval_ms: interval, + ..Default::default() + } + .validate() + .is_err()); + } + } + use super::*; use crate::scanning::Session; fn engine(config: Config) -> Session { diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index 40bce8f3..4881cd8d 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -378,21 +378,15 @@ 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 | 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]) + current.saturating_sub(100).max(100) } else { - RATES.iter().copied().find(|v| *v > current).unwrap_or(5000) + current.saturating_add(100).min(10000) }; if config.scan_preferences.menu.interval_ms.is_some() { config.scan_preferences.menu.interval_ms = Some(next); @@ -640,25 +634,25 @@ mod tests { assert!(visited.contains(&Kind::Zoom)); } #[test] - fn settings_step_presets_and_clamp_at_limits() { + fn settings_step_tenths_and_clamp_at_limits() { let mut c = crate::point_scan::Config { block_interval_ms: 650, ..Default::default() }; Setting::FasterScan.apply(&mut c); - assert_eq!(c.block_interval_ms, 500); + assert_eq!(c.block_interval_ms, 550); Setting::SlowerScan.apply(&mut c); - assert_eq!(c.block_interval_ms, 750); - for _ in 0..20 { + assert_eq!(c.block_interval_ms, 650); + for _ in 0..110 { Setting::FasterScan.apply(&mut c); Setting::SlowerLine.apply(&mut c); } - assert_eq!((c.block_interval_ms, c.speed), (250, 0)); - for _ in 0..20 { + assert_eq!((c.block_interval_ms, c.speed), (100, 0)); + for _ in 0..110 { Setting::SlowerScan.apply(&mut c); Setting::FasterLine.apply(&mut c); } - assert_eq!((c.block_interval_ms, c.speed), (5000, 4)); + assert_eq!((c.block_interval_ms, c.speed), (10000, 4)); c.validate().unwrap(); } #[test] @@ -732,12 +726,12 @@ mod tests { 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.scan_preferences.menu.interval_ms, Some(600)); 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.block_interval_ms, 900); 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 index a6dd65c4..45b1d369 100644 --- a/src-tauri/src/scan_preferences.rs +++ b/src-tauri/src/scan_preferences.rs @@ -113,7 +113,7 @@ impl Preferences { automatic: local.automatic.unwrap_or(automatic), interval_ms: local .interval_ms - .filter(|v| (250..=5000).contains(v)) + .filter(|v| (100..=10000).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( @@ -146,7 +146,7 @@ pub fn deserialize_preferences<'de, D: serde::Deserializer<'de>>( &mut preferences.menu, &mut preferences.keyboard, ] { - local.interval_ms = local.interval_ms.filter(|v| (250..=5000).contains(v)); + local.interval_ms = local.interval_ms.filter(|v| (100..=10000).contains(v)); local.pass_limit = local.pass_limit.filter(|v| valid_passes(*v)); } Ok(preferences) diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 69f22190..df818839 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -279,17 +279,17 @@ it("keeps rate values in manual mode and preserves keyboard settings when resett 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("button", { name: "Decrease auto scan interval by 0.1 seconds" })); fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" })); - expect(screen.getByLabelText("Auto scan rate")).toBeDisabled(); - expect(screen.getByLabelText("Auto scan rate")).toHaveValue("500"); + expect(screen.getByRole("button", { name: "Decrease auto scan interval by 0.1 seconds" })).toBeDisabled(); + expect(screen.getByLabelText("Auto scan rate")).toHaveTextContent("0.9 s"); fireEvent.click(screen.getByRole("button", { name: "Use default for auto scan rate" })); await waitFor(() => expect(screen.getByRole("group", { name: "Auto scan rate setting" })).toHaveFocus()); - expect(screen.getByLabelText("Auto scan rate")).toBeDisabled(); - expect(screen.getByLabelText("Auto scan rate")).toHaveValue("1000"); + expect(screen.getByRole("button", { name: "Decrease auto scan interval by 0.1 seconds" })).toBeDisabled(); + expect(screen.getByLabelText("Auto scan rate")).toHaveTextContent("1 s"); 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("button", { name: "Decrease auto scan interval by 0.1 seconds" })).toBeEnabled(); + expect(screen.getByLabelText("Auto scan rate")).toHaveTextContent("1 s"); 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: {} }) }) @@ -364,3 +364,20 @@ it("saves the keyboard after-typing choice and preserves it while manual", async await waitFor(() => expect(screen.getByRole("button", { name: "Wait for Select" })).toBeEnabled()); expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true"); }); + +it.each([100, 250, 750, 10000])("adjusts and persists the shared interval from %i ms without rounding legacy values", async (rate) => { + mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" + ? { ...initial, config: { ...initial.config, blockIntervalMs: rate } } : { ...initial, ...args }); + render(); + await screen.findByText(initial.message); + const decrease = screen.getByRole("button", { name: "Decrease auto scan interval by 0.1 seconds" }); + const increase = screen.getByRole("button", { name: "Increase auto scan interval by 0.1 seconds" }); + expect(decrease).toHaveProperty("disabled", rate === 100); + expect(increase).toHaveProperty("disabled", rate === 10000); + const next = rate === 100 ? 200 : Math.max(100, rate - 100); + fireEvent.click(rate === 100 ? increase : decrease); + expect(screen.getByLabelText("Auto scan rate")).toHaveTextContent(`${next / 1000} s`); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: expect.objectContaining({ blockIntervalMs: next }) + })); +}); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index 519f1441..f8c85d6b 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -14,7 +14,6 @@ export const defaultScanPreferences: ScanPreferences = { direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', point: {}, menu: {}, keyboard: {}, }; -export const scanIntervals = [250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000]; export function sharedOptions(config: PointScanConfig): ScanOptions { const settings = config.scanPreferences ?? defaultScanPreferences; return { automatic: config.automatic, intervalMs: config.blockIntervalMs, color: config.scannerColor, diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 5b41ba89..f23486c3 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -1,6 +1,6 @@ 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 { areaOptions, sharedOptions, defaultScanPreferences, type ScanArea, type ScanOptions } from '../scanning/preferences'; import { SettingGroup, Toggle, OptionGroup } from './controls'; export function ScannerPreferences({ controller }: { controller: ScanningController }) { @@ -144,9 +144,11 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl onChange={value => update('keyboardWaitAfterTyping', value === 'wait')} note={{ summary: effective.automatic ? 'After typing a key or suggestion, wait for Select before scanning again.' : 'Used in automatic keyboard scanning. Your choice is kept while scanning manually.' }} />} {field('intervalMs', 'Auto scan rate', locked => <> - +
Auto scan rate
+ + {effective.intervalMs / 1000} s + +

{effective.automatic ? 'Time each row or item stays highlighted. Lower values scan faster. Line speed is separate.' : 'Used in automatic scanning. Your rate is kept while scanning manually.'}

)} {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)} />)} diff --git a/src/styles.css b/src/styles.css index da6a5e3f..488afc26 100644 --- a/src/styles.css +++ b/src/styles.css @@ -437,3 +437,7 @@ button:disabled { cursor: default; opacity: 0.55; } .scanner-area-badge { grid-column: 2; grid-row: 2 / 4; align-self: center; margin: 0; } .scanner-area-card > span:not(.scanner-area-badge) { grid-column: 1; } } + +.scan-rate-stepper { display: flex; align-items: center; gap: 8px; } +.scan-rate-stepper button { min-width: 44px; min-height: 44px; font-size: 22px; } +.scan-rate-stepper output { min-width: 72px; text-align: center; font-variant-numeric: tabular-nums; }