diff --git a/docs/point-scan.md b/docs/point-scan.md index d719ee66..29104f04 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -72,6 +72,6 @@ Every scanning-menu page ends with a navigation row containing Close menu. Subpa Radar sits in the middle of the mouse panel's arrows. It hides the panel and first picks an area of the display the pointer is on. The display is divided into quarters, and the areas are highlighted in turn, as point scanning highlights rows: top left, top right, bottom left, bottom right, then the whole display. Select chooses the highlighted area and moves the pointer to its centre. A dotted beam then sweeps clockwise from straight up, around that centre, while the pointer stays there. Select fixes the direction. The beam then grows out from the centre in that direction towards the edge of the area, never beyond it, and the pointer travels along as its tip, so it shows where the action will land and what it hovers over. The beam draws no marker of its own under the pointer. Select leaves the pointer at the tip. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held and drags along with the pointer, so Radar can also drop at a distance. -Areas move once each mouse panel interval. The beam turns as fast as a point 300 logical pixels out moves at the point speed, and the beam grows at the point speed. Manual mode steps one area, then 5 degrees, then 20 logical pixels. Reverse direction starts with the whole display and goes backwards through the areas, turns anticlockwise, and starts the beam at full length, shrinking towards the centre. Automatic mode, direction, pass limit and thickness come from the mouse panel's settings. The areas and beam are drawn in the cursor overlay's colour, so they match the pointer, and a chosen quarter stays outlined. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. The pointer moves at most once each scan tick, and only when the tip has reached another pixel, using the same display units as the final move, so scaled displays and displays left of or above the main one behave alike. Open Mouse, or reaching the pass limit without a choice, goes back a step: from the direction or distance to picking an area, and from picking an area to the panel, leaving the pointer where it is rather than returning it. When the display under the pointer changes, the panel comes back without moving the pointer. Escape, Stop, a mobile connection and shutdown end the radar like any other scan, leaving the pointer where it is and releasing a held drag. +Areas move once each mouse panel interval. The beam turns as fast as a point 300 logical pixels out moves at the radar speed, and the beam grows at the radar speed. Radar speed is set under Mouse scanning, on the same five steps as Line speed. It is saved as `radarSpeed`; until one is chosen it is `null` and the radar follows Line speed, as it did before the setting existed. Faster lines and Slower lines in the scanning commands change Line speed only, so they move the radar too only while it follows Line speed. Synced settings moved to schema version 4 for `radarSpeed`. Older documents read with the radar following Line speed, and older versions ask to be updated rather than overwrite a version 4 document. Manual mode steps one area, then 5 degrees, then 20 logical pixels. Reverse direction starts with the whole display and goes backwards through the areas, turns anticlockwise, and starts the beam at full length, shrinking towards the centre. Automatic mode, direction, pass limit and thickness come from the mouse panel's settings. The areas and beam are drawn in the cursor overlay's colour, so they match the pointer, and a chosen quarter stays outlined. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. The pointer moves at most once each scan tick, and only when the tip has reached another pixel, using the same display units as the final move, so scaled displays and displays left of or above the main one behave alike. Open Mouse, or reaching the pass limit without a choice, goes back a step: from the direction or distance to picking an area, and from picking an area to the panel, leaving the pointer where it is rather than returning it. When the display under the pointer changes, the panel comes back without moving the pointer. Escape, Stop, a mobile connection and shutdown end the radar like any other scan, leaving the pointer where it is and releasing a held drag. The areas are drawn with point scanning's grid, fill and strips, and the beam is up to 32 small squares drawn with the same click-through, nonactivating strips, so no full-screen bitmap is drawn. The chosen area and centre are held in memory only. The pointer moves only through the existing input adapter's absolute move: to the chosen area's centre, along with the beam's tip, and for the final selection. Automated tests use fake input. diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 36906b6e..1c3a6088 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -71,6 +71,9 @@ pub struct Config { pub mode: Mode, pub automatic: bool, pub speed: usize, + /// The radar's speed on the same scale. `None`, as saved before it + /// existed, follows `speed`. + pub radar_speed: Option, pub grid_size: usize, pub block_interval_ms: u64, pub auto_select_enabled: bool, @@ -96,6 +99,7 @@ impl Default for Config { mode: Mode::Line, automatic: true, speed: 2, + radar_speed: None, grid_size: 4, block_interval_ms: 1000, auto_select_enabled: false, @@ -168,6 +172,7 @@ impl Config { scanner_color: self.resolved(crate::scan_preferences::Area::Point).color, mode: self.mode, speed: self.speed, + radar_speed: self.radar_speed.unwrap_or(self.speed), grid_size: self.grid_size, block_interval_ms: self .resolved(crate::scan_preferences::Area::Point) @@ -196,6 +201,7 @@ pub struct PointSettings { pub scanner_color: crate::scanning::ScannerColor, pub mode: Mode, pub speed: usize, + pub radar_speed: usize, pub grid_size: usize, pub block_interval_ms: u64, pub auto_select_enabled: bool, @@ -204,6 +210,7 @@ pub struct PointSettings { impl PointSettings { fn validate(&self) -> Result<(), String> { if self.speed > 4 + || self.radar_speed > 4 || !(2..=10).contains(&self.grid_size) || !(100..=10000).contains(&self.block_interval_ms) || !(100..=100_000).contains(&self.auto_select_delay_ms) @@ -767,6 +774,30 @@ mod tests { } } #[test] + fn radar_speed_follows_line_speed_until_chosen() { + let legacy: Config = serde_json::from_str(r#"{"speed":4}"#).unwrap(); + assert_eq!(legacy.radar_speed, None); + assert_eq!(legacy.point().radar_speed, 4); + for radar_speed in [None, Some(0), Some(4)] { + let config = Config { + speed: 1, + radar_speed, + ..Default::default() + }; + let restored: Config = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert_eq!(restored, config); + assert_eq!(restored.point().radar_speed, radar_speed.unwrap_or(1)); + assert_eq!(restored.point().speed, 1); + assert!(restored.validate().is_ok()); + } + let too_fast = Config { + radar_speed: Some(5), + ..Default::default() + }; + assert!(too_fast.validate().is_err()); + } + #[test] fn auto_select_delays_are_bounded_and_round_trip() { for delay in [100, 500, 1000, 100_000] { let config = Config { @@ -1188,6 +1219,7 @@ mod tests { json["enhancedWordPrediction"] = serde_json::json!(false); json["keyboardWaitAfterTyping"] = serde_json::json!(false); json["panelAvoidsPointer"] = serde_json::json!(false); + json["radarSpeed"] = serde_json::Value::Null; json["scanPreferences"] = serde_json::to_value(crate::scan_preferences::Preferences::default()).unwrap(); assert_eq!(serde_json::to_value(&config).unwrap(), json); diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 73c9ca33..2a8fad31 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -739,7 +739,7 @@ impl Workflow { self.mouse.choose(key); self.radar = Some(crate::scan_radar::Radar::new( self.point.config.mouse_scan, - self.point.config.speed, + self.point.config.radar_speed, self.point.screen, self.point.units_per_logical_pixel, )); @@ -1822,6 +1822,41 @@ mod tests { ); } + #[test] + fn radar_moves_at_the_radar_speed_or_else_the_line_speed() { + use crate::scan_mouse::Key; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + // Very slow is 45 logical pixels a second; very fast is 270. + for (radar_speed, reach) in [(None, 45), (Some(4), 270), (Some(0), 45)] { + let config = Config { + speed: 0, + radar_speed, + ..last_mode() + }; + let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap(); + workflow.handle(Action::OpenMouse); + workflow.set_pointer(Some((640.0, 360.0))); + workflow.mouse_key(Key::Radar); + // The whole display, then straight up from its centre. + for _ in 0..4 { + workflow.handle(Action::Next); + } + workflow.handle(Action::Select); + workflow.handle(Action::Select); + workflow.advance(1000); + assert_eq!( + workflow.radar.as_ref().unwrap().target(), + (640, 360 - reach), + "{radar_speed:?}" + ); + } + } + #[test] fn radar_sweeps_from_the_chosen_area_and_returns_to_the_mouse_panel() { use crate::scan_mouse::Key; diff --git a/src-tauri/src/portable_settings.rs b/src-tauri/src/portable_settings.rs index ef359148..941049ec 100644 --- a/src-tauri/src/portable_settings.rs +++ b/src-tauri/src/portable_settings.rs @@ -16,7 +16,7 @@ use serde_json::Value; use std::collections::HashSet; use tauri::{AppHandle, Manager}; -pub const SCHEMA_VERSION: u32 = 3; +pub const SCHEMA_VERSION: u32 = 4; const MAX_CUSTOM_PROFILES: usize = 32; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -195,8 +195,9 @@ pub fn parse(mut value: Value, schema_version: u32) -> Result value = upgrade_v2(upgrade_v1(value)?)?, - 2 => value = upgrade_v2(value)?, + 1 => value = upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?, + 2 => value = upgrade_v3(upgrade_v2(value)?)?, + 3 => value = upgrade_v3(value)?, SCHEMA_VERSION => {} _ => { return Err(ParseError::Invalid(format!( @@ -260,6 +261,21 @@ fn upgrade_v2(mut value: Value) -> Result { .ok_or_else(invalid)? .insert("rowGroups".into(), Value::Null); } + value["schemaVersion"] = Value::from(3); + Ok(value) +} + +/// Version 3 had no radar speed, so the radar follows the line speed. +fn upgrade_v3(mut value: Value) -> Result { + if value.get("schemaVersion") != Some(&Value::from(3)) { + return Err(ParseError::Invalid("version mismatch".into())); + } + value + .get_mut("pointScan") + .and_then(Value::as_object_mut) + .filter(|point_scan| !point_scan.contains_key("radarSpeed")) + .ok_or_else(|| ParseError::Invalid("it does not match version 3 settings".into()))? + .insert("radarSpeed".into(), Value::Null); value["schemaVersion"] = Value::from(SCHEMA_VERSION); Ok(value) } @@ -453,6 +469,12 @@ pub fn apply(app: &AppHandle, incoming: Document) -> Result { /// A current document as an older version wrote it. #[cfg(test)] pub(crate) fn written_by(mut value: Value, version: u32) -> Value { + if version < 4 { + value["pointScan"] + .as_object_mut() + .unwrap() + .remove("radarSpeed"); + } if version < 3 { let preferences = value .pointer_mut("/pointScan/scanPreferences") @@ -620,6 +642,47 @@ mod tests { written_by(serde_json::to_value(local().document()).unwrap(), 1) } + #[test] + fn version_three_documents_read_with_the_radar_following_line_speed() { + let mut local = local(); + local.point_scan.radar_speed = Some(4); + let mut cloud = local.clone(); + cloud.point_scan.radar_speed = None; + let version_three = written_by(serde_json::to_value(cloud.document()).unwrap(), 3); + let parsed = parse(version_three.clone(), 3).unwrap(); + assert_eq!(parsed.schema_version, SCHEMA_VERSION); + assert_eq!(parsed.point_scan.radar_speed, None); + let point_scan = plan(&local, parsed).unwrap().point_scan.unwrap(); + assert_eq!(point_scan.radar_speed, None); + + // Version 3 never had a radar speed, and a version 4 document needs it. + let mut early = version_three.clone(); + early["pointScan"]["radarSpeed"] = json!(4); + assert!(matches!(parse(early, 3), Err(ParseError::Invalid(_)))); + let mut missing = version_three; + missing["schemaVersion"] = json!(SCHEMA_VERSION); + assert!(matches!( + parse(missing, SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + )); + // A merge base saved by version 3 still loads. + let base: Document = serde_json::from_value(written_by( + serde_json::to_value(local.document()).unwrap(), + 3, + )) + .unwrap(); + assert_eq!(base.point_scan.radar_speed, None); + + // A chosen radar speed syncs, and an invalid one is refused. + let mut chosen = serde_json::to_value(cloud.document()).unwrap(); + chosen["pointScan"]["radarSpeed"] = json!(0); + let parsed = parse(chosen.clone(), SCHEMA_VERSION).unwrap(); + assert_eq!(parsed.point_scan.radar_speed, Some(0)); + chosen["pointScan"]["radarSpeed"] = json!(5); + let parsed = parse(chosen, SCHEMA_VERSION).unwrap(); + assert!(plan(&local, parsed).is_err()); + } + #[test] fn version_two_documents_read_with_row_groups_off_and_inherited() { let mut local = local(); @@ -1004,6 +1067,7 @@ mod tests { "nextKey", "panelAvoidsPointer", "pauseKey", + "radarSpeed", "scanPreferences", "scannerColor", "selectKey", diff --git a/src-tauri/src/scan_radar.rs b/src-tauri/src/scan_radar.rs index 94bf02b0..d854e2c1 100644 --- a/src-tauri/src/scan_radar.rs +++ b/src-tauri/src/scan_radar.rs @@ -9,7 +9,7 @@ use crate::{ }; use std::f64::consts::TAU; -/// The beam turns as fast as a point this far out moves at the point speed. +/// The beam turns as fast as a point this far out moves at the radar speed. const SWEEP_RADIUS: f64 = 300.0; /// Manual steps, in degrees and logical pixels. const ANGLE_STEP: f64 = 5.0; @@ -60,7 +60,7 @@ fn centre(rect: Rect) -> (f64, f64) { pub struct Radar { options: Resolved, - /// Logical pixels per second, the same as point scanning. + /// Logical pixels per second, on point scanning's line speed scale. speed: f64, screen: Rect, units: f64, @@ -85,7 +85,8 @@ pub struct Radar { } impl Radar { - /// `speed` is the point scan speed index. It starts by choosing an area of `screen`. + /// `speed` is the radar speed index, on the line speed scale. It starts by + /// choosing an area of `screen`. pub fn new(options: Resolved, speed: usize, screen: Rect, units: f64) -> Self { let mut radar = Self { options, diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index c6a840a4..b5c6ef4b 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -4,6 +4,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { @@ -427,6 +428,26 @@ it("brings the area header into view with help expanded and restores the overvie } }); +it("sets the radar speed on the mouse page, following line speed until chosen", async () => { + mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? { ...initial, config: { ...defaultPointScanConfig, speed: 1, radarSpeed: undefined } } : { ...initial, config: args.config }); + render(); + await screen.findByText(initial.message); + fireEvent.click(screen.getByRole("button", { name: "Customise mouse scanning" })); + const radar = screen.getByRole("group", { name: "Radar speed" }); + expect(within(radar).getByRole("button", { name: "Slow" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/Follows Line speed in Point scanning until you choose a speed here/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Follow line speed" })).not.toBeInTheDocument(); + fireEvent.click(within(radar).getByRole("button", { name: "Very fast" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: { ...defaultPointScanConfig, speed: 1, radarSpeed: 4 } })); + expect(within(screen.getByRole("group", { name: "Radar speed" })).getByRole("button", { name: "Very fast" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Follow line speed" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: { ...defaultPointScanConfig, speed: 1, radarSpeed: null } })); + const slow = within(screen.getByRole("group", { name: "Radar speed" })).getByRole("button", { name: "Slow" }); + expect(slow).toHaveAttribute("aria-pressed", "true"); + // Focus stays on the speeds when the button goes. + await waitFor(() => expect(slow).toHaveFocus()); +}); + it("saves the shared panel pointer-avoidance choice from the mouse, keyboard and menu pages", async () => { mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? initial : { ...initial, config: args.config }); render(); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 96e5c661..0d3f3680 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -18,6 +18,8 @@ export type PointScanConfig = { mode: "line" | "grid"; automatic: boolean; speed: number; + /** The radar's speed on the line speed scale. Null, or missing in older saved state, follows `speed`. */ + radarSpeed?: number | null; gridSize: number; blockIntervalMs: number; autoSelectEnabled: boolean; @@ -59,6 +61,7 @@ export const defaultPointScanConfig: PointScanConfig = { mode: "line", automatic: true, speed: 2, + radarSpeed: null, gridSize: 4, blockIntervalMs: 1000, autoSelectEnabled: false, diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 0e3df5f5..d1117f8d 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -7,6 +7,8 @@ import { SettingGroup, Toggle, OptionGroup } from './controls'; import { api } from '../api'; import type { AppSettings } from '../types'; +const speedOptions = ["Very slow", "Slow", "Medium", "Fast", "Very fast"].map((label, value) => ({ label, value })); + export function ScannerPreferences({ controller, settings: appSettings, onSettingsChange }: { controller: ScanningController; settings?: AppSettings; onSettingsChange?: (next: AppSettings) => void }) { const { config, state, update, chooseKeyboardLayout, pending } = controller; const [area, setArea] = useState<'shared' | ScanArea>('shared'); @@ -97,6 +99,23 @@ export function ScannerPreferences({ controller, settings: appSettings, onSettin /> } {area === 'mouse' &&

The ring stays visible while Mouse is open. Select a direction to move or a scroll tile to scroll; with Repeat mouse movement and scrolling on, {mouseRepeatStopInstruction(config).toLowerCase()} either action. Actions opens editing, window, browser, media, display and scanning commands, then returns to Mouse. Modified clicks use the current pointer. End a drag before opening Actions. Pointer speed and repeat controls are under Mouse in the sidebar. Switch to Point returns to screen selection.

} + {area === 'mouse' && + + legend="Radar speed" + columns="five" + disabled={disabled} + value={config.radarSpeed ?? config.speed} + onChange={value => update('radarSpeed', value)} + options={speedOptions} + note={{ summary: 'How fast the beam turns and grows. Areas move at the auto scan rate.' + (config.radarSpeed == null ? ' Follows Line speed in Point scanning until you choose a speed here.' : '') }} + /> + {config.radarSpeed != null && } + } {area === 'point' && <> update("speed", value)} - options={["Very slow", "Slow", "Medium", "Fast", "Very fast"].map( - (label, value) => ({ label, value }), - )} + options={speedOptions} + note={config.radarSpeed == null ? { summary: "Radar in Mouse scanning follows this speed until it has its own." } : undefined} /> {config.mode === "grid" && (