From cbb43728c197085986df25962784e4124c14bd33 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 11 Oct 2026 12:06:58 +0100 Subject: [PATCH 1/6] Add radar pointer control to the mouse panel A Radar key in the middle of the mouse panel's arrows sweeps a dotted beam around the pointer. Select fixes the direction, a marker travels out to the display edge, and Select moves the pointer there before returning to the panel. Closes #1013 Co-Authored-By: Claude Opus 5.5 --- docs/point-scan.md | 8 + src-tauri/src/lib.rs | 1 + src-tauri/src/point_workflow.rs | 167 +++++++++++++- src-tauri/src/scan_mouse.rs | 25 ++- src-tauri/src/scan_radar.rs | 371 +++++++++++++++++++++++++++++++ src/scanning/useScanning.ts | 2 +- src/settings/ScanningSection.tsx | 1 + 7 files changed, 561 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/scan_radar.rs diff --git a/docs/point-scan.md b/docs/point-scan.md index a9008c8e..2569ccea 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -67,3 +67,11 @@ Select opens a scanned Home menu, centred on the display under the pointer, when Home offers Point, Mouse, Keyboard, Apps and windows, Editing, Browser, Media, Switchify and Close menu. Every Home action works without a chosen point, so Home ignores which window is in front: Apps and windows can bring another app forward with switches, and nobody needs to focus an app before scanning. A command chosen in Home, or in a page opened from it, returns to Home. The keyboard opened from Home returns there when closed. Switchify shows the settings window and ends the scan. A point chosen after leaving Home keeps the usual foreground check. Home is also a tile in the Point action menu and Mouse Actions. Every scanning-menu page ends with a navigation row containing Close menu. Subpages retain Back, the main page retains New point, drag confirmation retains Cancel drag, and Home, which has nothing to go back to, ends with Close menu. Close menu ends the current scan and clears its points, pending selection and parent-page history without issuing desktop input. Select starts a fresh scan afterward. Pages use up to three columns and four rows; native layout scales the entire menu to fit the display. Pause/resume and emergency-stop behavior are unchanged. + +## Radar + +Radar sits in the middle of the mouse panel's arrows. It hides the panel and sweeps a dotted beam clockwise from straight up, around the pointer where it is now. Select fixes the direction. A marker then moves out along the beam to the edge of the display, and Select moves the pointer there. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held, so Radar can also drop at a distance. + +The beam turns as fast as a point 300 logical pixels out moves at the point speed, and the marker moves at the point speed. Manual mode steps 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the marker at the edge. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. When the pass limit is reached without a choice, or 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. + +The beam is up to 32 small squares drawn with the same click-through, nonactivating strips as point scanning, so no full-screen bitmap is drawn. The origin is held in memory only. The pointer moves only with the final absolute move, through the existing input adapter. Automated tests use fake input. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb3a0f06..c56bb7a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod scan_menu; mod scan_mouse; mod scan_panel; mod scan_preferences; +mod scan_radar; mod scan_tile; mod scan_tree; mod scanning; diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 8139c4c3..d34614c1 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -103,6 +103,7 @@ pub enum WorkflowPhase { MouseSuspended, MouseMoving, MouseScrolling, + MouseRadar, Menu, MenuSuspended, AutoSelecting, @@ -117,6 +118,7 @@ enum Stage { Mouse, MouseMoving, MouseScrolling, + MouseRadar, Idle, Countdown, Point, @@ -154,6 +156,8 @@ pub struct Workflow { mouse_actions_open: bool, move_repeat: Option, scroll_repeat: Option, + /// Chooses a pointer target for the mouse panel, which it hides meanwhile. + radar: Option, mouse_repeat_enabled: bool, mouse_move_interval_ms: u32, mouse_scroll_interval_ms: u32, @@ -203,6 +207,7 @@ impl Workflow { mouse_actions_open: false, move_repeat: None, scroll_repeat: None, + radar: None, mouse_repeat_enabled: true, mouse_move_interval_ms: 250, mouse_scroll_interval_ms: 250, @@ -253,7 +258,7 @@ impl Workflow { fn active_preferences(&self) -> crate::scan_preferences::Resolved { match self.stage { Stage::Keyboard | Stage::KeyboardOpening => self.point.config.keyboard_scan, - Stage::Mouse | Stage::MouseMoving | Stage::MouseScrolling => { + Stage::Mouse | Stage::MouseMoving | Stage::MouseScrolling | Stage::MouseRadar => { self.point.config.mouse_scan } Stage::Menu => self.point.config.menu_scan, @@ -291,6 +296,7 @@ impl Workflow { self.mouse_actions_open = false; self.move_repeat = None; self.scroll_repeat = None; + self.radar = None; self.key_repeat = None; self.parent_menu.clear(); self.begin_point(); @@ -311,7 +317,7 @@ impl Workflow { pub fn mouse_open(&self) -> bool { matches!( self.stage, - Stage::Mouse | Stage::MouseMoving | Stage::MouseScrolling + Stage::Mouse | Stage::MouseMoving | Stage::MouseScrolling | Stage::MouseRadar ) || (self.stage == Stage::Menu && self.mouse_actions_open) } pub fn control_mode(&self) -> crate::point_scan::ControlMode { @@ -344,11 +350,15 @@ impl Workflow { } pub fn set_mouse_area(&mut self, area: Rect, screen: Rect, scale: f64, displays: usize) { if self.point.screen != screen - && matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) + && matches!( + self.stage, + Stage::MouseMoving | Stage::MouseScrolling | Stage::MouseRadar + ) { self.stage = Stage::Mouse; self.move_repeat = None; self.scroll_repeat = None; + self.radar = None; self.pending = None; self.mouse.restart(); } @@ -550,6 +560,7 @@ impl Workflow { self.return_to_home = false; self.move_repeat = None; self.scroll_repeat = None; + self.radar = None; self.mouse = self.new_mouse(); self.pending = None; self.parent_menu.clear(); @@ -579,6 +590,22 @@ impl Workflow { return None; } } + if key == Key::Radar { + let Some(origin) = self.pointer else { + self.mouse.block_actions("Pointer unavailable · Try again"); + return None; + }; + self.mouse.choose(key); + self.radar = Some(crate::scan_radar::Radar::new( + self.point.config.mouse_scan, + self.point.config.speed, + self.point.screen, + self.point.units_per_logical_pixel, + origin, + )); + self.stage = Stage::MouseRadar; + return None; + } self.mouse.choose(key); self.set_dock(self.mouse.dock); match key { @@ -654,7 +681,19 @@ impl Workflow { self.open(Kind::MouseActions); None } - Key::More | Key::Movement | Key::Dock | Key::Position(_) | Key::Back => None, + Key::Radar | Key::More | Key::Movement | Key::Dock | Key::Position(_) | Key::Back => { + None + } + } + } + /// Leaves the radar for the mouse panel. A chosen target is an action. + fn end_radar(&mut self, chosen: bool) { + self.radar = None; + self.stage = Stage::Mouse; + if chosen { + self.mouse.repeat_stopped(); + } else { + self.mouse.restart(); } } /// What the scanned keyboard's choice asks for. A navigation key also @@ -888,6 +927,7 @@ impl Technique for Workflow { self.parent_menu.clear(); self.move_repeat = None; self.scroll_repeat = None; + self.radar = None; self.mouse.dragging = false; self.mouse.failed(); self.error = None; @@ -950,6 +990,7 @@ impl Technique for Workflow { self.mouse_actions_open = false; self.move_repeat = None; self.scroll_repeat = None; + self.radar = None; self.key_repeat = None; self.stage = Stage::Idle; self.parent_menu.clear(); @@ -1040,6 +1081,12 @@ impl Technique for Workflow { } } Stage::MouseMoving | Stage::MouseScrolling => {} + Stage::MouseRadar => { + if let Some((x, y)) = self.radar.as_mut().and_then(|radar| radar.handle(action)) { + self.end_radar(true); + return Some(Request::MouseMoveAbsolute { x, y }); + } + } Stage::Point | Stage::Destination => { if let Some(point) = self.point.handle(action) { if self.stage == Stage::Destination { @@ -1086,6 +1133,14 @@ impl Technique for Workflow { Stage::Mouse => self .mouse .advance(ms, self.point.config.mouse_scan.interval_ms), + Stage::MouseRadar => { + if let Some(radar) = self.radar.as_mut() { + radar.advance(ms); + if radar.exhausted() { + self.end_radar(false); + } + } + } Stage::Point | Stage::Destination => { self.point.advance(ms); if self.point.exhausted() { @@ -1181,6 +1236,7 @@ impl Technique for Workflow { }), Stage::MouseMoving => Phase::Workflow(WorkflowPhase::MouseMoving), Stage::MouseScrolling => Phase::Workflow(WorkflowPhase::MouseScrolling), + Stage::MouseRadar => Phase::Workflow(WorkflowPhase::MouseRadar), Stage::Keyboard => Phase::Workflow(if self.key_repeating() { WorkflowPhase::KeyboardRepeating } else if self.keyboard.suspended() { @@ -1262,6 +1318,11 @@ impl Technique for Workflow { ..Frame::default() } } + Stage::MouseRadar => self + .radar + .as_ref() + .map(crate::scan_radar::Radar::frame) + .unwrap_or_default(), Stage::Point | Stage::Destination => self.point.frame(), Stage::Menu if self.home_open() => self .menu @@ -1555,6 +1616,96 @@ mod tests { ); } + #[test] + fn radar_moves_the_pointer_from_where_it_is_and_returns_to_the_mouse_panel() { + use crate::scan_mouse::Key; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); + workflow.handle(Action::OpenMouse); + // Without a pointer there is nothing to sweep from. + assert_eq!(workflow.mouse_key(Key::Radar), None); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + assert_eq!( + workflow + .mouse + .frame(screen, 1.0, Default::default(), None) + .tiles + .last() + .unwrap() + .text, + "Pointer unavailable · Try again" + ); + + workflow.set_pointer(Some((640.0, 360.0))); + assert_eq!(workflow.mouse_key(Key::Radar), None); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::MouseRadar)); + assert!(workflow.mouse_open()); + let frame = workflow.frame(); + assert!(frame.tiles.is_empty(), "the panel is hidden"); + assert_eq!(frame.label.unwrap().text, "Radar · Select a direction"); + // A pointer moving the panel out of its way does not move the radar. + workflow.set_pointer(Some((10.0, 10.0))); + + // Straight up, then four manual steps out. + assert_eq!(workflow.handle(Action::Select), None); + for _ in 0..4 { + assert_eq!(workflow.handle(Action::Next), None); + } + assert_eq!( + workflow.handle(Action::Select), + Some(Request::MouseMoveAbsolute { x: 640, y: 280 }) + ); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + assert!(workflow.radar.is_none()); + + // A change of display ends the radar without moving the pointer. + workflow.mouse_key(Key::Radar); + workflow.set_mouse_area( + screen, + Rect { + x: 1280.0, + ..screen + }, + 1.0, + 2, + ); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + assert!(workflow.radar.is_none()); + } + + #[test] + fn radar_returns_to_the_panel_after_unattended_passes() { + use crate::scan_mouse::Key; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut s = Session::new( + Workflow::new(last_mode().point(), screen, 1.0).unwrap(), + false, + ); + s.action(Action::Select); + s.technique.handle(Action::OpenMouse); + s.technique.set_pointer(Some((640.0, 360.0))); + s.technique.mouse_key(Key::Radar); + for _ in 0..100_000 { + if s.technique.stage != Stage::MouseRadar { + break; + } + s.tick(100, false); + assert_eq!(s.take_selection(), None); + } + assert_eq!(s.technique.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + assert!(s.active()); + } + #[test] fn mouse_actions_use_the_current_pointer_and_return_to_mouse() { use crate::scan_menu::Command; @@ -1757,7 +1908,13 @@ mod tests { }; let mut session = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); session.action(Action::OpenMouse); - for action in [Action::Next, Action::Next, Action::Select, Action::Next] { + for action in [ + Action::Next, + Action::Next, + Action::Select, + Action::Next, + Action::Next, + ] { assert_eq!(session.action(action), None); } assert!(matches!( diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 3d61926a..718e6ad9 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -9,6 +9,8 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Key { Move(i8, i8), + /// Chooses a target with a radar sweep from the pointer. + Radar, Click, RightClick, DoubleClick, @@ -80,7 +82,7 @@ impl MousePanel { vec![ vec![Click, RightClick, DoubleClick, Drag], vec![Move(-1, -1), Move(0, -1), Move(1, -1), Scroll(1)], - vec![Move(-1, 0), Move(1, 0)], + vec![Move(-1, 0), Radar, Move(1, 0)], vec![Move(-1, 1), Move(0, 1), Move(1, 1), Scroll(-1)], vec![Actions, More, Keyboard, Close], ] @@ -116,10 +118,12 @@ impl MousePanel { } // The panel is left behind. Key::Actions | Key::Keyboard | Key::Close => self.scan.restart(), + // The radar returns through `repeat_stopped` or `restart`. + Key::Radar => {} _ => self.scan.continue_after_selection(), } } - /// A repeating move or scroll was stopped by a switch. + /// A repeating move or scroll was stopped by a switch, or the radar moved the pointer. pub fn repeat_stopped(&mut self) { self.scan.continue_after_selection(); } @@ -177,6 +181,7 @@ impl MousePanel { Move(0, 1) => "↓".into(), Move(1, 1) => "↘".into(), Move(..) => "Move".into(), + Radar => "Radar".into(), Click => "Left click".into(), RightClick => "Right click".into(), DoubleClick => "Double click".into(), @@ -281,8 +286,8 @@ impl MousePanel { }, blank: false, }; - // Keep ← and → under the diagonal arrows. - let blank = matches!(key, Key::Move(_, 0)).then(PanelKey::blank); + // Keep → under its diagonal arrow, with Radar between. + let blank = matches!(key, Key::Move(1, 0)).then(PanelKey::blank); std::iter::once(tile).chain(blank) }) .collect() @@ -341,9 +346,10 @@ mod tests { assert!(tile.rect.x + tile.rect.width <= screen.x + width + 1e-8); assert!(tile.rect.y + tile.rect.height <= screen.y + height + 1e-8); } - // Background, four actions, four upper directions, then left/right. + // Background, four actions, four upper directions, then left, Radar, right. assert_eq!(frame.tiles[5].rect.x, frame.tiles[9].rect.x); - assert_eq!(frame.tiles[7].rect.x, frame.tiles[10].rect.x); + assert_eq!(frame.tiles[6].rect.x, frame.tiles[10].rect.x); + assert_eq!(frame.tiles[7].rect.x, frame.tiles[11].rect.x); assert!((frame.tiles[1].scale - scale).abs() < 1e-8); mouse.error = true; assert_eq!( @@ -370,7 +376,7 @@ mod tests { [ vec![Click, RightClick, DoubleClick, Drag], vec![Move(-1, -1), Move(0, -1), Move(1, -1), Scroll(1)], - vec![Move(-1, 0), Move(1, 0)], + vec![Move(-1, 0), Radar, Move(1, 0)], vec![Move(-1, 1), Move(0, 1), Move(1, 1), Scroll(-1)], vec![Actions, More, Keyboard, Close], ] @@ -580,12 +586,15 @@ mod tests { assert_eq!(tile(&frame, "←").width, tile(&frame, "↖").width); assert_eq!(tile(&frame, "→").x, tile(&frame, "↗").x); assert_eq!(tile(&frame, "→").width, tile(&frame, "↗").width); - assert_eq!(frame.tiles.len(), 1 + 18 + 1, "spacers draw no tiles"); + assert_eq!(frame.tiles.len(), 1 + 19 + 1, "spacers draw no tiles"); + assert_eq!(tile(&frame, "Radar").x, tile(&frame, "↑").x); panel.handle(Action::Next); panel.handle(Action::Next); panel.handle(Action::Select); panel.handle(Action::Next); + assert_eq!(panel.handle(Action::Select), Some(Key::Radar)); + panel.handle(Action::Next); let frame = panel.frame(screen, 1.0, ScannerColor::default(), None); let selected: Vec<_> = frame.tiles.iter().filter(|tile| tile.selected).collect(); assert_eq!(selected.len(), 1); diff --git a/src-tauri/src/scan_radar.rs b/src-tauri/src/scan_radar.rs new file mode 100644 index 00000000..72f50d91 --- /dev/null +++ b/src-tauri/src/scan_radar.rs @@ -0,0 +1,371 @@ +//! Radar pointer targeting for the mouse panel. A beam turns around the +//! pointer, then a marker travels out along it. Pure geometry; no OS input. +use crate::{ + scan_preferences::{Direction, Resolved}, + scanning::{Action, Frame, FrameLabel, HudPresentation, Rect}, +}; +use std::f64::consts::TAU; + +/// The beam turns as fast as a point this far out moves at the point speed. +const SWEEP_RADIUS: f64 = 300.0; +/// Manual steps, in degrees and logical pixels. +const ANGLE_STEP: f64 = 5.0; +const DISTANCE_STEP: f64 = 20.0; +/// The beam is drawn as separate squares, each its own native strip. +const MAX_DOTS: usize = 32; +const DOT_SPACING: f64 = 16.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Angle, + Distance, +} + +pub struct Radar { + options: Resolved, + /// Logical pixels per second, the same as point scanning. + speed: f64, + screen: Rect, + units: f64, + origin: (f64, f64), + pub phase: Phase, + /// Radians clockwise from straight up. + angle: f64, + /// Display units from the origin along the beam. + distance: f64, + direction: f64, + /// Full automatic passes of the current phase since it began. + cycles: usize, +} + +impl Radar { + /// `speed` is the point scan speed index. The origin is kept on the display. + pub fn new( + options: Resolved, + speed: usize, + screen: Rect, + units: f64, + origin: (f64, f64), + ) -> Self { + let mut radar = Self { + options, + speed: [45.0, 75.0, 120.0, 180.0, 270.0][speed.min(4)], + screen, + units, + origin: ( + origin.0.clamp(screen.x, screen.x + screen.width - 1.0), + origin.1.clamp(screen.y, screen.y + screen.height - 1.0), + ), + phase: Phase::Angle, + angle: 0.0, + distance: 0.0, + direction: 1.0, + cycles: 0, + }; + radar.direction = radar.initial_direction(); + radar + } + fn initial_direction(&self) -> f64 { + if self.options.direction == Direction::Forward { + 1.0 + } else { + -1.0 + } + } + fn unit(&self) -> (f64, f64) { + (self.angle.sin(), -self.angle.cos()) + } + /// How far the beam reaches before leaving the display. + pub fn reach(&self) -> f64 { + let (dx, dy) = self.unit(); + let (x, y) = self.origin; + let right = self.screen.x + self.screen.width - 1.0; + let bottom = self.screen.y + self.screen.height - 1.0; + let along = |position: f64, delta: f64, low: f64, high: f64| { + if delta > 1e-9 { + (high - position) / delta + } else if delta < -1e-9 { + (low - position) / delta + } else { + f64::INFINITY + } + }; + along(x, dx, self.screen.x, right) + .min(along(y, dy, self.screen.y, bottom)) + .max(0.0) + } + fn at(&self, distance: f64) -> (f64, f64) { + let (dx, dy) = self.unit(); + (self.origin.0 + dx * distance, self.origin.1 + dy * distance) + } + pub fn target(&self) -> (i32, i32) { + let (x, y) = self.at(self.distance); + ( + x.round() + .clamp(self.screen.x, self.screen.x + self.screen.width - 1.0) as i32, + y.round() + .clamp(self.screen.y, self.screen.y + self.screen.height - 1.0) as i32, + ) + } + pub fn exhausted(&self) -> bool { + self.options.exhausted(self.cycles) + } + /// Select fixes the angle, then returns the chosen point. + pub fn handle(&mut self, action: Action) -> Option<(i32, i32)> { + match action { + Action::Select => { + self.cycles = 0; + self.direction = self.initial_direction(); + match self.phase { + Phase::Angle => { + self.phase = Phase::Distance; + self.distance = if self.direction > 0.0 { + 0.0 + } else { + self.reach() + }; + } + Phase::Distance => return Some(self.target()), + } + } + Action::Next | Action::Back => { + self.direction = if action == Action::Next { 1.0 } else { -1.0 }; + match self.phase { + Phase::Angle => { + self.turn(self.direction * ANGLE_STEP.to_radians()); + } + Phase::Distance => { + self.extend(self.direction * DISTANCE_STEP * self.units); + } + } + } + Action::Reverse => self.direction = -self.direction, + _ => {} + } + None + } + /// Moves automatically. Only these passes count towards the pass limit. + pub fn advance(&mut self, ms: u64) { + let seconds = ms as f64 / 1000.0; + let wrapped = match self.phase { + Phase::Angle => self.turn(self.direction * self.speed / SWEEP_RADIUS * seconds), + Phase::Distance => { + let amount = (self.speed * self.units * seconds).max(1.0); + self.extend(self.direction * amount) + } + }; + if wrapped { + self.cycles += 1; + } + } + /// Turns the beam and reports whether it passed straight up. Leaving + /// straight up backwards at the start is not a pass. + fn turn(&mut self, delta: f64) -> bool { + let next = self.angle + delta; + let wrapped = next >= TAU || (next < 0.0 && self.angle > 0.0); + self.angle = next.rem_euclid(TAU); + wrapped + } + /// Moves the marker and reports whether it wrapped past either end. + fn extend(&mut self, delta: f64) -> bool { + let reach = self.reach(); + let next = self.distance + delta; + if next > reach { + self.distance = 0.0; + true + } else if next < 0.0 { + self.distance = reach; + true + } else { + self.distance = next; + false + } + } + fn square(&self, (x, y): (f64, f64), size: f64) -> Rect { + Rect { + x: x - size / 2.0, + y: y - size / 2.0, + width: size, + height: size, + } + } + pub fn frame(&self) -> Frame { + let t = 2.0 * self.units * self.options.thickness.scale(); + let reach = self.reach(); + let dots = ((reach / (DOT_SPACING * self.units)).ceil() as usize).clamp(1, MAX_DOTS); + let beam: Vec = (1..=dots) + .map(|dot| self.square(self.at(reach * dot as f64 / dots as f64), t * 2.0)) + .collect(); + let origin = self.square(self.origin, t * 4.0); + let mut strips = vec![]; + let mut fills = vec![]; + match self.phase { + Phase::Angle => { + strips.push(origin); + strips.extend(beam); + } + Phase::Distance => { + // The chosen direction stays faint while the marker moves. + fills.push(origin); + fills.extend(beam); + strips.extend(crate::scan_menu::outline( + self.square(self.at(self.distance), 20.0 * self.units), + t, + )); + } + } + let width = (480.0 * self.units).min(self.screen.width); + Frame { + fills, + strips, + label: Some(FrameLabel { + text: match self.phase { + Phase::Angle => "Radar · Select a direction", + Phase::Distance => "Radar · Select a distance", + } + .into(), + rect: Rect { + x: self.screen.x + (self.screen.width - width) / 2.0, + y: self.screen.y + 20.0 * self.units, + width, + height: (64.0 * self.units).min(self.screen.height), + }, + scale: self.units, + hud: Some(HudPresentation { + screen: self.screen, + scale: self.units, + }), + }), + ..Frame::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCREEN: Rect = Rect { + x: -1000.0, + y: 100.0, + width: 1000.0, + height: 800.0, + }; + + fn radar(origin: (f64, f64)) -> Radar { + Radar::new(Resolved::default(), 2, SCREEN, 1.0, origin) + } + + #[test] + fn beam_reaches_the_display_edge_in_each_direction() { + let mut r = radar((-500.0, 500.0)); + assert!((r.reach() - 400.0).abs() < 1e-9, "up"); + r.angle = TAU / 4.0; + assert!((r.reach() - 499.0).abs() < 1e-9, "right"); + r.angle = TAU / 2.0; + assert!((r.reach() - 399.0).abs() < 1e-9, "down"); + r.angle = TAU * 3.0 / 4.0; + assert!((r.reach() - 500.0).abs() < 1e-9, "left"); + r.angle = TAU / 8.0; + let (x, y) = r.at(r.reach()); + assert!((y - 100.0).abs() < 1e-6 && x > -500.0 && x < -1.0); + } + + #[test] + fn select_fixes_the_angle_then_returns_the_marker_on_the_display() { + let mut r = radar((-500.0, 500.0)); + r.handle(Action::Next); + r.handle(Action::Next); + assert!((r.angle - 10f64.to_radians()).abs() < 1e-9); + assert_eq!(r.handle(Action::Select), None); + assert_eq!(r.phase, Phase::Distance); + assert_eq!(r.target(), (-500, 500)); + for _ in 0..10 { + r.handle(Action::Next); + } + let (x, y) = r.target(); + let expected = ( + -500.0 + 200.0 * 10f64.to_radians().sin(), + 500.0 - 200.0 * 10f64.to_radians().cos(), + ); + assert_eq!( + (x, y), + (expected.0.round() as i32, expected.1.round() as i32) + ); + assert_eq!(r.handle(Action::Select), Some((x, y))); + } + + #[test] + fn origin_off_the_display_is_brought_onto_it() { + let r = radar((50.0, -20.0)); + assert_eq!(r.origin, (-1.0, 100.0)); + assert_eq!(r.target(), (-1, 100)); + } + + #[test] + fn automatic_passes_count_towards_the_limit_but_manual_steps_do_not() { + let mut r = radar((-500.0, 500.0)); + for _ in 0..200 { + r.handle(Action::Back); + } + assert!(!r.exhausted()); + // Speed 120 at a 300 pixel radius takes about 15.7 s per turn. + for _ in 0..50 { + r.advance(1000); + } + assert_eq!(r.cycles, 3); + assert!(r.exhausted()); + + r.angle = 0.0; + r.handle(Action::Select); + assert_eq!(r.cycles, 0); + for _ in 0..4 { + r.advance(1000); + } + assert_eq!(r.cycles, 1, "400 pixels up at 120 per second wraps once"); + assert!(r.distance < 120.0); + } + + #[test] + fn reverse_direction_turns_anticlockwise_and_starts_at_the_edge() { + let mut r = Radar::new( + Resolved { + direction: Direction::Reverse, + ..Default::default() + }, + 4, + SCREEN, + 1.0, + (-500.0, 500.0), + ); + r.advance(100); + assert!(r.angle > TAU * 0.9); + assert_eq!(r.cycles, 0, "leaving straight up is not a pass"); + r.handle(Action::Select); + assert!((r.distance - r.reach()).abs() < 1e-9); + r.advance(100); + assert!(r.distance < r.reach()); + } + + #[test] + fn frame_draws_a_bounded_beam_inside_the_display_and_labels_the_phase() { + let mut r = radar((-500.0, 500.0)); + r.angle = TAU / 8.0; + let frame = r.frame(); + assert!(frame.strips.len() <= MAX_DOTS + 1); + assert!(frame.fills.is_empty()); + assert_eq!( + frame.label.as_ref().unwrap().text, + "Radar · Select a direction" + ); + r.handle(Action::Select); + let frame = r.frame(); + assert_eq!(frame.strips.len(), 4, "marker outline"); + assert!(!frame.fills.is_empty()); + assert_eq!(frame.label.unwrap().text, "Radar · Select a distance"); + for rect in frame.fills.iter().chain(&frame.strips) { + assert!(rect.x + rect.width / 2.0 >= SCREEN.x - 1e-6); + assert!(rect.y + rect.height / 2.0 >= SCREEN.y - 1e-6); + } + } +} diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 50ba120f..96e5c661 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -41,7 +41,7 @@ export type PointScanState = { remote?: boolean; config: PointScanConfig; enabled: boolean; - phase: "autoSelecting" | "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing" | "keyboard" | "keyboardSuspended" | "keyboardOpening" | "keyboardRepeating" | "mouse" | "mouseSuspended" | "mouseMoving" | "mouseScrolling"; + phase: "autoSelecting" | "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing" | "keyboard" | "keyboardSuspended" | "keyboardOpening" | "keyboardRepeating" | "mouse" | "mouseSuspended" | "mouseMoving" | "mouseScrolling" | "mouseRadar"; paused: boolean; message: string; supported: boolean; diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 6f6014c1..87c35f8f 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -12,6 +12,7 @@ const phases = { mouseSuspended: "Select to resume the mouse", mouseMoving: "Moving pointer", mouseScrolling: "Scrolling", + mouseRadar: "Choose a direction, then a distance, to move the pointer", idle: "Ready to begin", autoSelecting: "Waiting to click. Press a switch for the action menu", menu: "Choose an action at the selected point", From fd36215581c64cf0879d70b2c7aa1ac730602c82 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 11 Oct 2026 12:34:59 +0100 Subject: [PATCH 2/6] Address radar review findings Give each automatic radar pass at least one mouse panel interval, so a short beam near a display edge is not used up within a few ticks. Keep the drag cue during radar, let Open Mouse return to the panel, and clear radar state when the panel is left for the keyboard. Co-Authored-By: Claude Opus 5.5 --- docs/point-scan.md | 2 +- src-tauri/src/point_workflow.rs | 20 ++++++++++++++++++++ src-tauri/src/scan_radar.rs | 27 +++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 2569ccea..1bf7e948 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 sweeps a dotted beam clockwise from straight up, around the pointer where it is now. Select fixes the direction. A marker then moves out along the beam to the edge of the display, and Select moves the pointer there. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held, so Radar can also drop at a distance. -The beam turns as fast as a point 300 logical pixels out moves at the point speed, and the marker moves at the point speed. Manual mode steps 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the marker at the edge. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. When the pass limit is reached without a choice, or 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. +The beam turns as fast as a point 300 logical pixels out moves at the point speed, and the marker moves at the point speed. Manual mode steps 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the marker at the edge. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. When the pass limit is reached without a choice, when Open Mouse is pressed, or 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. The beam is up to 32 small squares drawn with the same click-through, nonactivating strips as point scanning, so no full-screen bitmap is drawn. The origin is held in memory only. The pointer moves only with the final absolute move, through the existing input adapter. Automated tests use fake input. diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index d34614c1..d7e82969 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -447,6 +447,8 @@ impl Workflow { PointerFeedback::Move }), Stage::Menu if self.mouse_actions_open => Some(PointerFeedback::Move), + // A drag stays held while the radar chooses where to drop it. + Stage::MouseRadar if self.mouse.dragging => Some(PointerFeedback::Drag), _ => None, } } @@ -737,6 +739,7 @@ impl Workflow { self.keyboard_output(output) } fn return_to_mouse_panel(&mut self) { + self.radar = None; self.parent_menu.clear(); self.mouse_actions_open = false; self.stage = Stage::Mouse; @@ -783,6 +786,7 @@ impl Workflow { self.mouse.dragging = false; } self.mouse_actions_open = false; + self.radar = None; self.stage = Stage::KeyboardOpening; self.keyboard = self.new_keyboard(); self.pending = None; @@ -1055,6 +1059,10 @@ impl Technique for Workflow { return self.open_point(); } if action == Action::OpenMouse { + if self.stage == Stage::MouseRadar { + self.end_radar(false); + return None; + } if self.mouse_open() { return None; } @@ -1676,6 +1684,18 @@ mod tests { ); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); assert!(workflow.radar.is_none()); + + // A held drag keeps its cue, and Open Mouse goes back without moving. + workflow.mouse.dragging = true; + workflow.mouse_key(Key::Radar); + assert_eq!( + workflow.mouse_feedback(), + Some(crate::input::PointerFeedback::Drag) + ); + assert_eq!(workflow.handle(Action::OpenMouse), None); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + assert!(workflow.radar.is_none()); + assert!(workflow.mouse.dragging); } #[test] diff --git a/src-tauri/src/scan_radar.rs b/src-tauri/src/scan_radar.rs index 72f50d91..2cf9003b 100644 --- a/src-tauri/src/scan_radar.rs +++ b/src-tauri/src/scan_radar.rs @@ -36,6 +36,8 @@ pub struct Radar { direction: f64, /// Full automatic passes of the current phase since it began. cycles: usize, + /// Time since the last counted pass, so a short beam is not used up at once. + pass_ms: u64, } impl Radar { @@ -61,6 +63,7 @@ impl Radar { distance: 0.0, direction: 1.0, cycles: 0, + pass_ms: 0, }; radar.direction = radar.initial_direction(); radar @@ -115,6 +118,7 @@ impl Radar { match action { Action::Select => { self.cycles = 0; + self.pass_ms = 0; self.direction = self.initial_direction(); match self.phase { Phase::Angle => { @@ -144,8 +148,10 @@ impl Radar { } None } - /// Moves automatically. Only these passes count towards the pass limit. + /// Moves automatically. Only these passes count towards the pass limit, + /// and a pass lasts at least one scan interval, however short the beam. pub fn advance(&mut self, ms: u64) { + self.pass_ms = self.pass_ms.saturating_add(ms); let seconds = ms as f64 / 1000.0; let wrapped = match self.phase { Phase::Angle => self.turn(self.direction * self.speed / SWEEP_RADIUS * seconds), @@ -154,8 +160,9 @@ impl Radar { self.extend(self.direction * amount) } }; - if wrapped { + if wrapped && self.pass_ms >= self.options.interval_ms { self.cycles += 1; + self.pass_ms = 0; } } /// Turns the beam and reports whether it passed straight up. Leaving @@ -326,6 +333,22 @@ mod tests { assert!(r.distance < 120.0); } + #[test] + fn a_beam_with_no_room_still_gives_each_pass_a_full_interval() { + // On the top edge, straight up reaches nowhere. + let mut r = radar((-500.0, 100.0)); + r.handle(Action::Select); + assert_eq!(r.reach(), 0.0); + for _ in 0..29 { + r.advance(100); + } + assert_eq!(r.cycles, 2, "one pass per 1000 ms interval"); + assert!(!r.exhausted()); + r.advance(100); + assert!(r.exhausted()); + assert_eq!(r.handle(Action::Select), Some((-500, 100))); + } + #[test] fn reverse_direction_turns_anticlockwise_and_starts_at_the_edge() { let mut r = Radar::new( From e38bf2aca513d6ece36fbf39b38a84443e0867a4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 11 Oct 2026 12:37:04 +0100 Subject: [PATCH 3/6] Do not assert a held drag after Open Mouse in the radar test The runtime rebuilds the engine for Open Mouse, which releases held input, so the workflow-only assertion described behaviour that never happens. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/point_workflow.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index d7e82969..da373e8c 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -1686,6 +1686,7 @@ mod tests { assert!(workflow.radar.is_none()); // A held drag keeps its cue, and Open Mouse goes back without moving. + // (The runtime rebuilds the engine for Open Mouse, which also releases the drag.) workflow.mouse.dragging = true; workflow.mouse_key(Key::Radar); assert_eq!( @@ -1695,7 +1696,6 @@ mod tests { assert_eq!(workflow.handle(Action::OpenMouse), None); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); assert!(workflow.radar.is_none()); - assert!(workflow.mouse.dragging); } #[test] From 236bfcf931374869901860b74647e0a1d8704897 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 11 Oct 2026 13:05:25 +0100 Subject: [PATCH 4/6] Grow the radar beam out to the pointer target Replace the moving square outline in the distance step with a beam that grows from the pointer in the chosen direction. Select moves the pointer to the beam's tip. Co-Authored-By: Claude Opus 5.5 --- docs/point-scan.md | 2 +- src-tauri/src/scan_radar.rs | 57 +++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 1bf7e948..a2cf773a 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -70,7 +70,7 @@ Every scanning-menu page ends with a navigation row containing Close menu. Subpa ## Radar -Radar sits in the middle of the mouse panel's arrows. It hides the panel and sweeps a dotted beam clockwise from straight up, around the pointer where it is now. Select fixes the direction. A marker then moves out along the beam to the edge of the display, and Select moves the pointer there. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held, so Radar can also drop at a distance. +Radar sits in the middle of the mouse panel's arrows. It hides the panel and sweeps a dotted beam clockwise from straight up, around the pointer where it is now. Select fixes the direction. The beam then grows out from the pointer in that direction towards the edge of the display, and Select moves the pointer to its tip. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held, so Radar can also drop at a distance. The beam turns as fast as a point 300 logical pixels out moves at the point speed, and the marker moves at the point speed. Manual mode steps 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the marker at the edge. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. When the pass limit is reached without a choice, when Open Mouse is pressed, or 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. diff --git a/src-tauri/src/scan_radar.rs b/src-tauri/src/scan_radar.rs index 2cf9003b..5e685507 100644 --- a/src-tauri/src/scan_radar.rs +++ b/src-tauri/src/scan_radar.rs @@ -200,30 +200,25 @@ impl Radar { let t = 2.0 * self.units * self.options.thickness.scale(); let reach = self.reach(); let dots = ((reach / (DOT_SPACING * self.units)).ceil() as usize).clamp(1, MAX_DOTS); - let beam: Vec = (1..=dots) - .map(|dot| self.square(self.at(reach * dot as f64 / dots as f64), t * 2.0)) - .collect(); - let origin = self.square(self.origin, t * 4.0); - let mut strips = vec![]; - let mut fills = vec![]; + let spacing = reach / dots as f64; + let mut strips = vec![self.square(self.origin, t * 4.0)]; match self.phase { - Phase::Angle => { - strips.push(origin); - strips.extend(beam); - } + Phase::Angle => strips + .extend((1..=dots).map(|dot| self.square(self.at(spacing * dot as f64), t * 2.0))), Phase::Distance => { - // The chosen direction stays faint while the marker moves. - fills.push(origin); - fills.extend(beam); - strips.extend(crate::scan_menu::outline( - self.square(self.at(self.distance), 20.0 * self.units), - t, - )); + // The beam grows out from the pointer to its tip, where the pointer goes. + strips.extend( + (1..=dots) + .map(|dot| spacing * dot as f64) + // Leave room so no dot sits on the tip. + .take_while(|&along| along < self.distance - spacing / 2.0) + .map(|along| self.square(self.at(along), t * 2.0)), + ); + strips.push(self.square(self.at(self.distance), t * 3.0)); } } let width = (480.0 * self.units).min(self.screen.width); Frame { - fills, strips, label: Some(FrameLabel { text: match self.phase { @@ -381,12 +376,32 @@ mod tests { frame.label.as_ref().unwrap().text, "Radar · Select a direction" ); + let full = frame.strips.len(); r.handle(Action::Select); let frame = r.frame(); - assert_eq!(frame.strips.len(), 4, "marker outline"); - assert!(!frame.fills.is_empty()); + assert_eq!( + frame.strips.len(), + 2, + "the beam starts as the origin and its tip" + ); + assert!(frame.fills.is_empty()); assert_eq!(frame.label.unwrap().text, "Radar · Select a distance"); - for rect in frame.fills.iter().chain(&frame.strips) { + + // It grows with the distance, and its tip is where the pointer goes. + r.distance = r.reach() / 2.0; + let half = r.frame(); + assert!(half.strips.len() > 2 && half.strips.len() < full); + let tip = half.strips.last().unwrap(); + let (x, y) = r.target(); + assert!((tip.x + tip.width / 2.0 - f64::from(x)).abs() <= 1.0); + assert!((tip.y + tip.height / 2.0 - f64::from(y)).abs() <= 1.0); + r.distance = r.reach(); + assert_eq!( + r.frame().strips.len(), + full, + "the full beam ends in its tip" + ); + for rect in &r.frame().strips { assert!(rect.x + rect.width / 2.0 >= SCREEN.x - 1e-6); assert!(rect.y + rect.height / 2.0 >= SCREEN.y - 1e-6); } From d7802ebbf11994d7857a37631b5e50864c5ce975 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 11 Oct 2026 13:06:47 +0100 Subject: [PATCH 5/6] Describe the growing radar beam consistently in the docs Co-Authored-By: Claude Opus 5.5 --- docs/point-scan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index a2cf773a..2cc84713 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 sweeps a dotted beam clockwise from straight up, around the pointer where it is now. Select fixes the direction. The beam then grows out from the pointer in that direction towards the edge of the display, and Select moves the pointer to its tip. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held, so Radar can also drop at a distance. -The beam turns as fast as a point 300 logical pixels out moves at the point speed, and the marker moves at the point speed. Manual mode steps 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the marker at the edge. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. When the pass limit is reached without a choice, when Open Mouse is pressed, or 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. +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 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the beam at full length, shrinking towards the pointer. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. When the pass limit is reached without a choice, when Open Mouse is pressed, or 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. The beam is up to 32 small squares drawn with the same click-through, nonactivating strips as point scanning, so no full-screen bitmap is drawn. The origin is held in memory only. The pointer moves only with the final absolute move, through the existing input adapter. Automated tests use fake input. From 352a7e62624faedf1cf034f40e8d32c319b7ccae Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 11 Oct 2026 13:11:28 +0100 Subject: [PATCH 6/6] Draw the radar in the cursor overlay colour The radar beam now uses the cursor overlay's colour setting instead of the mouse panel's scan colour, so it matches the pointer. Co-Authored-By: Claude Opus 5.5 --- docs/point-scan.md | 2 +- src-tauri/src/overlay.rs | 11 +++++++++++ src-tauri/src/point_scan_runtime.rs | 3 +++ src-tauri/src/point_workflow.rs | 18 +++++++++++++++++- src-tauri/src/scanning.rs | 10 ++++++++++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 2cc84713..0e004302 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 sweeps a dotted beam clockwise from straight up, around the pointer where it is now. Select fixes the direction. The beam then grows out from the pointer in that direction towards the edge of the display, and Select moves the pointer to its tip. The panel comes back and carries on as it does after any other mouse action. A drag in progress stays held, so Radar can also drop at a distance. -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 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the beam at full length, shrinking towards the pointer. Automatic mode, direction, pass limit, colour and thickness come from the mouse panel's settings. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. When the pass limit is reached without a choice, when Open Mouse is pressed, or 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. +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 5 degrees, then 20 logical pixels. Reverse direction turns anticlockwise and starts the beam at full length, shrinking towards the pointer. Automatic mode, direction, pass limit and thickness come from the mouse panel's settings. The beam is drawn in the cursor overlay's colour, so it matches the pointer. Each automatic pass lasts at least one mouse panel interval, so a short beam near an edge is not used up at once. When the pass limit is reached without a choice, when Open Mouse is pressed, or 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. The beam is up to 32 small squares drawn with the same click-through, nonactivating strips as point scanning, so no full-screen bitmap is drawn. The origin is held in memory only. The pointer moves only with the final absolute move, through the existing input adapter. Automated tests use fake input. diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index d3209f5f..d188962c 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -1470,6 +1470,17 @@ mod tests { } } + #[test] + fn scanner_colours_match_the_cursor_overlay_colours() { + for name in ["red", "green", "blue", "yellow", "white", "purple"] { + assert_eq!( + crate::scanning::ScannerColor::from_cursor_overlay(name).rgb(), + color_for(name), + "{name}" + ); + } + } + #[test] fn dwell_progress_fills_the_native_countdown_ring() { let early = render_marker( diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 64b878bb..a337f76b 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -299,6 +299,9 @@ impl Adapter for PointScan { settings.scroll_repeat_interval_ms, settings.mouse_repeat_enabled, ); + technique.set_cursor_color(crate::scanning::ScannerColor::from_cursor_overlay( + &settings.cursor_overlay_color, + )); } else if technique.follows_foreground() { environment.foreground = crate::scan_host::foreground().ok(); environment.panel = false; diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index da373e8c..3e459007 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -158,6 +158,8 @@ pub struct Workflow { scroll_repeat: Option, /// Chooses a pointer target for the mouse panel, which it hides meanwhile. radar: Option, + /// The radar is drawn in the cursor overlay's colour once it is known. + cursor_color: Option, mouse_repeat_enabled: bool, mouse_move_interval_ms: u32, mouse_scroll_interval_ms: u32, @@ -208,6 +210,7 @@ impl Workflow { move_repeat: None, scroll_repeat: None, radar: None, + cursor_color: None, mouse_repeat_enabled: true, mouse_move_interval_ms: 250, mouse_scroll_interval_ms: 250, @@ -393,6 +396,9 @@ impl Workflow { self.mouse.restart(); } } + pub fn set_cursor_color(&mut self, color: crate::scanning::ScannerColor) { + self.cursor_color = Some(color); + } pub fn set_key_repeat_settings( &mut self, enabled: bool, @@ -1390,7 +1396,10 @@ impl Technique for Workflow { } } let preferences = self.active_preferences(); - frame.color = preferences.color; + frame.color = match (&self.stage, self.cursor_color) { + (Stage::MouseRadar, Some(color)) => color, + _ => preferences.color, + }; for tile in &mut frame.tiles { tile.color = frame.color; tile.thickness = preferences.thickness; @@ -1653,7 +1662,14 @@ mod tests { assert_eq!(workflow.mouse_key(Key::Radar), None); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::MouseRadar)); assert!(workflow.mouse_open()); + // Drawn in the mouse panel's colour until the cursor colour is known. + assert_eq!( + workflow.frame().color, + workflow.point.config.mouse_scan.color + ); + workflow.set_cursor_color(crate::scanning::ScannerColor::Yellow); let frame = workflow.frame(); + assert_eq!(frame.color, crate::scanning::ScannerColor::Yellow); assert!(frame.tiles.is_empty(), "the panel is hidden"); assert_eq!(frame.label.unwrap().text, "Radar · Select a direction"); // A pointer moving the panel out of its way does not move the radar. diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 227438c8..de2c1743 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -126,6 +126,16 @@ pub enum ScannerColor { White, } impl ScannerColor { + /// The cursor overlay's saved colour name, which falls back to red as the overlay does. + pub fn from_cursor_overlay(name: &str) -> Self { + match name { + "green" => Self::Green, + "blue" => Self::Blue, + "yellow" => Self::Yellow, + "white" => Self::White, + _ => Self::Red, + } + } pub fn rgb(self) -> [u8; 3] { match self { Self::Red => [211, 47, 47],