diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 4b2a1471..407fa961 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -569,8 +569,8 @@ impl DesktopInput { (dy as f64 * scale).round() as i32, ) } - pub fn set_pointer_scale_percent(&mut self, scale_percent: u8) { - self.pointer_scale_percent = u32::from(scale_percent.clamp(5, 225)); + pub fn set_pointer_scale_percent(&mut self, scale_percent: u16) { + self.pointer_scale_percent = u32::from(scale_percent.clamp(5, 1350)); } #[cfg_attr( target_os = "macos", @@ -2267,6 +2267,14 @@ mod tests { assert_eq!(input.injector.moves, vec![(1, -1)]); } + #[test] + fn direct_pointer_movement_uses_the_extended_speed() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.set_pointer_scale_percent(1350); + input.move_pointer(12, -12).unwrap(); + assert_eq!(input.injector.moves, vec![(162, -162)]); + } + #[test] fn mobile_pointer_speed_changes_persist_and_restore() { let root = diff --git a/src-tauri/src/mouse_repeat.rs b/src-tauri/src/mouse_repeat.rs index 67f445e0..5cd835cd 100644 --- a/src-tauri/src/mouse_repeat.rs +++ b/src-tauri/src/mouse_repeat.rs @@ -174,7 +174,7 @@ impl ScanMoveRepeat { &mut self, elapsed_ms: u64, move_interval_ms: u32, - pointer_scale_percent: u8, + pointer_scale_percent: u16, acceleration_duration_ms: u32, ) -> (i32, i32) { let mut remaining = elapsed_ms.min(64); @@ -231,7 +231,7 @@ impl MoveRepeatState { dx: i32, dy: i32, move_interval_ms: u32, - pointer_scale_percent: u8, + pointer_scale_percent: u16, acceleration_duration_ms: u32, ) -> (i32, i32) { let elapsed_ms = now @@ -366,7 +366,7 @@ impl MouseRepeatController { generation: u64, now: Instant, move_interval_ms: u32, - pointer_scale_percent: u8, + pointer_scale_percent: u16, ) -> Option<(i32, i32)> { let active = self.active.get_mut(device_id)?; if active.generation != generation { @@ -658,40 +658,52 @@ mod tests { fn scanning_motion_matches_remote_repeat_ticks() { for direction in [(12, 0), (-12, 12), (0, -12)] { for acceleration in [0, 1000] { - let now = Instant::now(); - let mut scan = ScanMoveRepeat::new(direction.0, direction.1, now); - let mut remote = MouseRepeatController::default(); - let active = remote.start( - "remote".into(), - RepeatCommand::Move { - dx: direction.0, - dy: direction.1, - }, - acceleration, - now, - ); - assert_eq!( - scan.initial_move(), - remote.initial_move("remote", active.generation).unwrap() - ); - let mut time = now; - for _ in 0..40 { - let scanned = scan.advance(32, 250, 100, acceleration); - let mut expected = (0, 0); - for _ in 0..4 { - time += Duration::from_millis(8); - let delta = remote - .advance_move("remote", active.generation, time, 250, 100) - .unwrap(); - expected.0 += delta.0; - expected.1 += delta.1; + for speed in [100, 225, 1350] { + let now = Instant::now(); + let mut scan = ScanMoveRepeat::new(direction.0, direction.1, now); + let mut remote = MouseRepeatController::default(); + let active = remote.start( + "remote".into(), + RepeatCommand::Move { + dx: direction.0, + dy: direction.1, + }, + acceleration, + now, + ); + assert_eq!( + scan.initial_move(), + remote.initial_move("remote", active.generation).unwrap() + ); + let mut time = now; + for _ in 0..40 { + let scanned = scan.advance(32, 250, speed, acceleration); + let mut expected = (0, 0); + for _ in 0..4 { + time += Duration::from_millis(8); + let delta = remote + .advance_move("remote", active.generation, time, 250, speed) + .unwrap(); + expected.0 += delta.0; + expected.1 += delta.1; + } + assert_eq!(scanned, expected); } - assert_eq!(scanned, expected); } } } } + #[test] + fn maximum_speed_covers_a_display_in_about_three_seconds() { + let mut scan = ScanMoveRepeat::new(12, 0, Instant::now()); + let mut distance = scan.initial_move().0; + for _ in 0..125 { + distance += scan.advance(8, 250, 1350, 0).0; + } + assert!((647..=649).contains(&distance)); + } + #[test] fn parses_only_bounded_move_and_scroll_commands() { assert_eq!( diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 43c66cca..9bf4de75 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -16,6 +16,34 @@ pub struct Environment { pub struct PointScan; pub type Controller = scanning_runtime::Controller; pub type View = scanning_runtime::View; +fn next_pointer_speed(current: u16, direction: i8) -> u16 { + if direction > 0 { + let step = if current < 100 { + 5 + } else if current < 300 { + 50 + } else { + 100 + }; + let next = current / step * step + step; + next.min(1350) + } else if direction < 0 { + if current > 1300 { + 1300 + } else { + let step = if current <= 100 { + 5 + } else if current <= 300 { + 50 + } else { + 100 + }; + ((current.saturating_sub(1)) / step * step).max(5) + } + } else { + current + } +} pub fn configure(app: &AppHandle, config: Config) -> Result { let previous = app.state::().view().config; if config.switches().keys() != previous.switches().keys() { @@ -128,7 +156,7 @@ impl Adapter for PointScan { Request::MouseSpeed(direction) => { let model = app.state::(); let old = model.snapshot().settings.pointer_scale_percent; - let next = (i16::from(old) + i16::from(direction) * 5).clamp(5, 225) as u8; + let next = next_pointer_speed(old, direction); model.apply_pointer_scale_percent(next)?; crate::state::emit_state(app, &model.shared); return Ok(None); @@ -295,6 +323,20 @@ mod tests { use super::*; use crate::scanning::Technique; + #[test] + fn mouse_speed_tiles_cover_precision_and_fast_range() { + assert_eq!(next_pointer_speed(5, -1), 5); + assert_eq!(next_pointer_speed(95, 1), 100); + assert_eq!(next_pointer_speed(100, 1), 150); + assert_eq!(next_pointer_speed(225, 1), 250); + assert_eq!(next_pointer_speed(225, -1), 200); + assert_eq!(next_pointer_speed(300, 1), 400); + assert_eq!(next_pointer_speed(400, -1), 300); + assert_eq!(next_pointer_speed(1300, 1), 1350); + assert_eq!(next_pointer_speed(1350, 1), 1350); + assert_eq!(next_pointer_speed(1350, -1), 1300); + } + #[test] fn saving_mode_change_keeps_other_settings_and_reloads() { let mut config = Config { diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 40aa0a0d..4d7974d7 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -218,7 +218,7 @@ impl Workflow { } pub fn set_mouse_settings( &mut self, - speed: u8, + speed: u16, acceleration_ms: u32, move_interval_ms: u32, repeat_enabled: bool, diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 5a6789f9..b81e3f5a 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -1456,7 +1456,7 @@ pub fn pointer_profile_response( "setSupported": true, "scalePercent": settings.pointer_scale_percent, "minScalePercent": 5, - "maxScalePercent": 225, + "maxScalePercent": 1350, "stepPercent": 5, "baseMoveDelta": 128, "effectiveMoveDelta": (128.0 * f64::from(settings.pointer_scale_percent) / 100.0).round() as u32 @@ -2625,6 +2625,10 @@ mod tests { assert_eq!(response["payload"]["maxDelta"], 500); assert_eq!(response["payload"]["recommendedDeltas"]["medium"], 130); assert_eq!(response["payload"]["capabilities"]["noAckMouseMove"], true); + assert_eq!( + response["payload"]["capabilities"]["pointerSpeed"]["maxScalePercent"], + 1350 + ); assert_eq!( response["payload"]["capabilities"]["mouseRepeat"]["supported"], true diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 339e4084..a1eea663 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -27,7 +27,7 @@ pub struct MousePanel { pub more: bool, pub top: bool, pub dragging: bool, - pub speed_percent: u8, + pub speed_percent: u16, pub error: bool, displays: usize, rows: Vec>, @@ -35,7 +35,7 @@ pub struct MousePanel { } impl MousePanel { - pub fn new(options: Resolved, displays: usize, speed_percent: u8) -> Self { + pub fn new(options: Resolved, displays: usize, speed_percent: u16) -> Self { let rows = Self::rows(false, displays); let scan = ItemScanner::configured_rows(&rows, Policy::KEYBOARD, options); Self { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 3d8fdf6a..73b596a1 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -58,7 +58,7 @@ pub struct Activity { #[serde(rename_all = "camelCase")] pub struct AppSettings { pub start_with_system: bool, - pub pointer_scale_percent: u8, + pub pointer_scale_percent: u16, pub mouse_repeat_enabled: bool, pub move_repeat_interval_ms: u32, pub scroll_repeat_interval_ms: u32, @@ -109,10 +109,10 @@ impl Default for AppSettings { impl AppSettings { pub fn normalized(mut self) -> Result { - if !(5..=225).contains(&self.pointer_scale_percent) + if !(5..=1350).contains(&self.pointer_scale_percent) || !self.pointer_scale_percent.is_multiple_of(5) { - return Err("Pointer speed must be between 5 and 225 in steps of 5.".into()); + return Err("Pointer speed must be between 5 and 1350 in steps of 5.".into()); } if ![100, 250, 500, 1000].contains(&self.move_repeat_interval_ms) || ![100, 250, 500, 1000].contains(&self.scroll_repeat_interval_ms) @@ -146,11 +146,11 @@ impl AppSettings { } } -pub fn normalize_pointer_scale_percent(scale_percent: f64) -> Result { +pub fn normalize_pointer_scale_percent(scale_percent: f64) -> Result { if !scale_percent.is_finite() || scale_percent <= 0.0 { return Err("Pointer speed is invalid.".into()); } - Ok(((scale_percent / 5.0).round() * 5.0).clamp(5.0, 225.0) as u8) + Ok(((scale_percent / 5.0).round() * 5.0).clamp(5.0, 1350.0) as u16) } fn default_cursor_overlay_visibility() -> String { @@ -541,7 +541,7 @@ impl AppModel { self.storage .save(&self.persisted_state(Some(settings), consent, None)) } - pub fn apply_pointer_scale_percent(&self, scale_percent: u8) -> Result<(), String> { + pub fn apply_pointer_scale_percent(&self, scale_percent: u16) -> Result<(), String> { let _transaction = self .persistence_lock .lock() @@ -1214,11 +1214,24 @@ mod tests { assert_eq!(normalize_pointer_scale_percent(1.0), Ok(5)); assert_eq!(normalize_pointer_scale_percent(122.0), Ok(120)); assert_eq!(normalize_pointer_scale_percent(123.0), Ok(125)); - assert_eq!(normalize_pointer_scale_percent(500.0), Ok(225)); + assert_eq!(normalize_pointer_scale_percent(500.0), Ok(500)); + assert_eq!(normalize_pointer_scale_percent(2000.0), Ok(1350)); assert!(normalize_pointer_scale_percent(0.0).is_err()); assert!(normalize_pointer_scale_percent(f64::NAN).is_err()); } #[test] + fn pointer_speed_settings_keep_old_values_and_round_trip_the_new_maximum() { + for speed in [5, 100, 225, 1350] { + let settings = AppSettings { + pointer_scale_percent: speed, + ..AppSettings::default() + }; + let restored: AppSettings = + serde_json::from_slice(&serde_json::to_vec(&settings).unwrap()).unwrap(); + assert_eq!(restored.normalized().unwrap().pointer_scale_percent, speed); + } + } + #[test] fn pointer_speed_transaction_preserves_a_serialized_settings_change() { let root = std::env::temp_dir().join(format!( "switchify-pointer-settings-race-{}", diff --git a/src/settings/PointerSection.tsx b/src/settings/PointerSection.tsx index 4a309485..d3cd2bfc 100644 --- a/src/settings/PointerSection.tsx +++ b/src/settings/PointerSection.tsx @@ -1,28 +1,37 @@ -import { Button, Select, MoreOptions } from "../ui/controls"; +import { Button, Input, MoreOptions } from "../ui/controls"; import { useEffect, useId, useState } from "react"; import type { AppSettings } from "../types"; import { Disclosure, OptionGroup, SettingGroup, Toggle, accelerationOptions, dwellDelayOptions, keyRepeatDelayOptions, - movementValue, pointerSpeedOptions, pointerSpeedValues, repeatIntervalOptions, secondsOptions, + movementValue, pointerSpeedOptions, repeatIntervalOptions, secondsOptions, type SettingsUpdate, } from "./controls"; export function PointerSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { - // The presets stop at 100%, so any value above that or off the 5/25/50/75 - // steps is only reachable through the exact-speed select. Reveal it whenever - // such a value is active, so the control that produced it is never hidden. + // Keep a draft so the user can type a multi-digit speed without saving each + // intermediate value or losing focus to a backend state update. const isPreset = (pointerSpeedOptions as readonly number[]).includes(settings.pointerScalePercent); const [showExact, setShowExact] = useState(!isPreset); + const [speedDraft, setSpeedDraft] = useState(String(settings.pointerScalePercent)); + useEffect(() => { setSpeedDraft(String(settings.pointerScalePercent)); }, [settings.pointerScalePercent]); useEffect(() => { if (!isPreset) setShowExact(true); }, [isPreset]); const exactSpeedId = useId(); + const saveExactSpeed = () => { + const entered = Number(speedDraft); + if (!Number.isFinite(entered) || entered <= 0) { + setSpeedDraft(String(settings.pointerScalePercent)); + return; + } + const next = Math.min(1350, Math.max(5, Math.round(entered / 5) * 5)); + setSpeedDraft(String(next)); + if (next !== settings.pointerScalePercent) update("pointerScalePercent", next); + }; return -
Pointer speed {settings.pointerScalePercent}%
+
Pointer speed {settings.pointerScalePercent}%

100% is the original speed. Higher speeds help cross the screen faster in Mouse and Remote.

{pointerSpeedOptions.map((value) => )}
setShowExact(!showExact)} controls={exactSpeedId}>
- +
{([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)}
diff --git a/src/settings/controls.tsx b/src/settings/controls.tsx index 4e795a3c..0de5897a 100644 --- a/src/settings/controls.tsx +++ b/src/settings/controls.tsx @@ -11,8 +11,7 @@ export function SettingGroup({ title, description, children, id, sectionRef, foc return

{title}

{description}

{children}
; } -export const pointerSpeedOptions = [5, 25, 50, 75, 100] as const; -export const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5); +export const pointerSpeedOptions = [5, 25, 50, 75, 100, 500, 1350] as const; export const repeatIntervalOptions = [100, 250, 500, 1000] as const; export const keyRepeatDelayOptions = [ { value: 0, label: "None" }, @@ -29,7 +28,7 @@ export const accelerationOptions = [ export const dwellDelayOptions = [500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] as const; export function movementValue(base: number, scale: number) { - const value = Math.min(50, Math.max(1, Math.round((base * scale / 100) * 2) / 2)); + const value = Math.max(1, Math.round((base * scale / 100) * 2) / 2); return Number.isInteger(value) ? String(value) : value.toFixed(1); } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index b4867dc0..264b3727 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -164,12 +164,13 @@ describe("Switchify PC settings", () => { expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true"); // Exact speed and the movement readout sit behind a disclosure by default. - expect(screen.queryByRole("combobox", { name: "Exact pointer speed" })).not.toBeInTheDocument(); + expect(screen.queryByRole("spinbutton", { name: "Exact pointer speed" })).not.toBeInTheDocument(); expect(screen.queryByText("2.5")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Set an exact speed" })); expect(screen.getByText("2.5")).toBeInTheDocument(); - fireEvent.change(screen.getByRole("combobox", { name: "Exact pointer speed" }), { target: { value: "125" } }); - expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("125"); + fireEvent.change(screen.getByRole("spinbutton", { name: "Exact pointer speed" }), { target: { value: "125" } }); + fireEvent.blur(screen.getByRole("spinbutton", { name: "Exact pointer speed" })); + expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(125); expect(screen.getByText("5.5")).toBeInTheDocument(); fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" })); expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled(); @@ -355,7 +356,7 @@ describe("Switchify PC settings", () => { fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 }))); - expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150"); + expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(150); selectTab("Privacy"); expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); @@ -604,7 +605,7 @@ describe("Switchify PC settings", () => { fireEvent.click(toggle); const hide = screen.getByRole("button", { name: "Hide exact speed" }); expect(hide).toHaveAttribute("aria-expanded", "true"); - expect(document.getElementById(hide.getAttribute("aria-controls")!)).toContainElement(screen.getByRole("combobox", { name: "Exact pointer speed" })); + expect(document.getElementById(hide.getAttribute("aria-controls")!)).toContainElement(screen.getByRole("spinbutton", { name: "Exact pointer speed" })); expect(screen.getByLabelText("Pointer movement values")).toBeInTheDocument(); }); @@ -615,7 +616,24 @@ describe("Switchify PC settings", () => { selectTab("Controls"); expect(screen.getByRole("button", { name: "Hide exact speed" })).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150"); + expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(150); + }); + + it("offers fast presets and rounds exact speed to the supported five percent step", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Controls"); + fireEvent.click(screen.getByRole("button", { name: "1350% pointer speed" })); + expect(screen.getByRole("button", { name: "1350% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Set an exact speed" })); + const exact = screen.getByRole("spinbutton", { name: "Exact pointer speed" }); + expect(exact).toHaveAttribute("min", "5"); + expect(exact).toHaveAttribute("max", "1350"); + expect(exact).toHaveAttribute("step", "5"); + fireEvent.change(exact, { target: { value: "1337" } }); + fireEvent.blur(exact); + expect(exact).toHaveValue(1335); + expect(screen.getByRole("button", { name: "Hide exact speed" })).toHaveAttribute("aria-expanded", "true"); }); it("expands the exact speed disclosure when the backend pushes a non-preset value", async () => { @@ -632,7 +650,7 @@ describe("Switchify PC settings", () => { act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 175 }))); expect(screen.getByRole("button", { name: "Hide exact speed" })).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("175"); + expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(175); }); diff --git a/src/styles.css b/src/styles.css index 1a398552..b19832a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -124,10 +124,11 @@ button:disabled { cursor: default; opacity: 0.55; } .pointer-speed legend, .repeat-options legend { width: 100%; margin-bottom: 8px; color: var(--muted); } .pointer-speed legend strong { float: right; color: inherit; } .segmented.compact.five { grid-template-columns: repeat(5, 1fr); } +.segmented.compact.speed-presets { grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); } .segmented.compact.four { grid-template-columns: repeat(4, 1fr); } -.segmented.compact.five button, .segmented.compact.four button { min-width: 0; padding: 0 6px; } +.segmented.compact.five button, .segmented.compact.four button, .segmented.compact.speed-presets button { min-width: 0; padding: 0 6px; } .exact-speed { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: var(--muted); } -.exact-speed select { min-width: 92px; min-height: 32px; border: 1px solid var(--border); border-radius: 5px; padding: 4px 8px; color: inherit; background: var(--surface); } +.exact-speed input { min-width: 92px; min-height: 32px; border: 1px solid var(--border); border-radius: 5px; padding: 4px 8px; color: inherit; background: var(--surface); } .movement-values { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 10px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } .movement-values div { display: grid; gap: 3px; padding: 10px 8px; text-align: center; } .movement-values div + div { border-left: 1px solid var(--border); }