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
12 changes: 10 additions & 2 deletions src-tauri/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,8 +569,8 @@ impl<I: InputInjector> DesktopInput<I> {
(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",
Expand Down Expand Up @@ -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 =
Expand Down
74 changes: 43 additions & 31 deletions src-tauri/src/mouse_repeat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down
44 changes: 43 additions & 1 deletion src-tauri/src/point_scan_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ pub struct Environment {
pub struct PointScan;
pub type Controller = scanning_runtime::Controller<PointScan>;
pub type View = scanning_runtime::View<Config, Phase>;
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<View, String> {
let previous = app.state::<Controller>().view().config;
if config.switches().keys() != previous.switches().keys() {
Expand Down Expand Up @@ -128,7 +156,7 @@ impl Adapter for PointScan {
Request::MouseSpeed(direction) => {
let model = app.state::<crate::state::AppModel>();
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);
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/scan_mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ 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<Vec<Key>>,
scan: ItemScanner<Key>,
}

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 {
Expand Down
27 changes: 20 additions & 7 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,10 +109,10 @@ impl Default for AppSettings {

impl AppSettings {
pub fn normalized(mut self) -> Result<Self, String> {
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)
Expand Down Expand Up @@ -146,11 +146,11 @@ impl AppSettings {
}
}

pub fn normalize_pointer_scale_percent(scale_percent: f64) -> Result<u8, String> {
pub fn normalize_pointer_scale_percent(scale_percent: f64) -> Result<u16, String> {
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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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-{}",
Expand Down
27 changes: 18 additions & 9 deletions src/settings/PointerSection.tsx
Original file line number Diff line number Diff line change
@@ -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 <SettingGroup title="Controls" description="">
<fieldset className="pointer-speed"><legend>Pointer speed <strong>{settings.pointerScalePercent}%</strong></legend><div className="segmented compact five">
<fieldset className="pointer-speed"><legend>Pointer speed <strong>{settings.pointerScalePercent}%</strong></legend><p>100% is the original speed. Higher speeds help cross the screen faster in Mouse and Remote.</p><div className="segmented compact speed-presets">
{pointerSpeedOptions.map((value) => <Button type="button" key={value} aria-label={`${value}% pointer speed`} aria-pressed={settings.pointerScalePercent === value} onClick={() => update("pointerScalePercent", value)}>{value}%</Button>)}
</div><Disclosure label={showExact ? "Hide exact speed" : "Set an exact speed"} expanded={showExact} onToggle={() => setShowExact(!showExact)} controls={exactSpeedId}>
<div id={exactSpeedId}>
<label className="exact-speed"><span>Exact speed</span><Select aria-label="Exact pointer speed" value={settings.pointerScalePercent} onChange={(event) => update("pointerScalePercent", Number(event.target.value))}>
{pointerSpeedValues.map((value) => <option key={value} value={value}>{value}%</option>)}
</Select></label>
<label className="exact-speed"><span>Exact speed (%)</span><Input type="number" aria-label="Exact pointer speed" min={5} max={1350} step={5} value={speedDraft} onChange={(event) => setSpeedDraft(event.target.value)} onBlur={saveExactSpeed} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }} /></label>
<div className="movement-values" aria-label="Pointer movement values">
{([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) => <div key={label}><span>{label}</span><strong>{movementValue(base, settings.pointerScalePercent)}</strong></div>)}
</div>
Expand Down
5 changes: 2 additions & 3 deletions src/settings/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ export function SettingGroup({ title, description, children, id, sectionRef, foc
return <section className="setting-group" id={id} ref={sectionRef} tabIndex={focusable ? -1 : undefined} aria-labelledby={headingId}><header><h2 id={headingId}>{title}</h2><p>{description}</p></header><div className="setting-controls">{children}</div></section>;
}

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" },
Expand All @@ -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);
}

Expand Down
Loading
Loading