Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/point-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
32 changes: 32 additions & 0 deletions src-tauri/src/point_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
pub grid_size: usize,
pub block_interval_ms: u64,
pub auto_select_enabled: bool,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 36 additions & 1 deletion src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
));
Expand Down Expand Up @@ -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;
Expand Down
70 changes: 67 additions & 3 deletions src-tauri/src/portable_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -195,8 +195,9 @@ pub fn parse(mut value: Value, schema_version: u32) -> Result<Document, ParseErr
return Err(ParseError::Newer(schema_version));
}
match schema_version {
1 => 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!(
Expand Down Expand Up @@ -260,6 +261,21 @@ fn upgrade_v2(mut value: Value) -> Result<Value, ParseError> {
.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<Value, ParseError> {
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)
}
Expand Down Expand Up @@ -453,6 +469,12 @@ pub fn apply(app: &AppHandle, incoming: Document) -> Result<Applied, String> {
/// 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")
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1004,6 +1067,7 @@ mod tests {
"nextKey",
"panelAvoidsPointer",
"pauseKey",
"radarSpeed",
"scanPreferences",
"scannerColor",
"selectKey",
Expand Down
7 changes: 4 additions & 3 deletions src-tauri/src/scan_radar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/PointScan.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
Expand Down Expand Up @@ -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(<PointScan />);
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(<PointScan />);
Expand Down
3 changes: 3 additions & 0 deletions src/scanning/useScanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,6 +61,7 @@ export const defaultPointScanConfig: PointScanConfig = {
mode: "line",
automatic: true,
speed: 2,
radarSpeed: null,
gridSize: 4,
blockIntervalMs: 1000,
autoSelectEnabled: false,
Expand Down
Loading
Loading