diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 612ba17c..0d48eafe 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -11,10 +11,18 @@ pub enum Mode { Line, Grid, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ControlMode { + #[default] + Point, + Mouse, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { + pub control_mode: ControlMode, #[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")] pub scan_preferences: crate::scan_preferences::Preferences, pub word_prediction: bool, @@ -35,6 +43,7 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { + control_mode: ControlMode::Point, scan_preferences: Default::default(), word_prediction: true, keyboard_wait_after_typing: false, @@ -91,6 +100,7 @@ impl Config { } pub fn point(&self) -> PointSettings { PointSettings { + control_mode: self.control_mode, scan: self.resolved(crate::scan_preferences::Area::Point), menu_scan: self.resolved(crate::scan_preferences::Area::Menu), keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard), @@ -115,6 +125,7 @@ impl Config { } #[derive(Clone)] pub struct PointSettings { + pub control_mode: ControlMode, pub scan: crate::scan_preferences::Resolved, pub menu_scan: crate::scan_preferences::Resolved, pub keyboard_scan: crate::scan_preferences::Resolved, @@ -284,6 +295,7 @@ impl Engine { | Action::Stop | Action::Cancel | Action::OpenKeyboard + | Action::OpenPoint | Action::OpenMouse => {} } None @@ -1006,6 +1018,7 @@ mod tests { json["autoSelectEnabled"] = serde_json::json!(false); json["autoSelectDelayMs"] = serde_json::json!(1000); json["scannerColor"] = serde_json::json!("blue"); + json["controlMode"] = serde_json::json!("point"); json["wordPrediction"] = serde_json::json!(true); json["keyboardWaitAfterTyping"] = serde_json::json!(false); json["scanPreferences"] = @@ -1031,6 +1044,7 @@ mod tests { fn legacy_empty_config_and_validation() { let c: Config = serde_json::from_str("{}").unwrap(); assert_eq!(c, Config::default()); + assert_eq!(c.control_mode, ControlMode::Point); assert!(Config { select_key: "Escape".into(), ..c.clone() diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 04b07b7c..43c66cca 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -41,6 +41,17 @@ impl Adapter for PointScan { type Environment = Environment; const EVENT: &'static str = "point-scan-changed"; const FILE: &'static str = "point-scan.json"; + fn sync_mode(config: &mut Config, technique: &Workflow) -> bool { + let next = technique.control_mode(); + if config.control_mode == next { + return false; + } + config.control_mode = next; + true + } + fn keep_runtime_config(next: &mut Config, current: &Config) { + next.control_mode = current.control_mode; + } fn cursor_feedback(technique: &Workflow) -> Option { technique.mouse_feedback() } @@ -91,7 +102,7 @@ impl Adapter for PointScan { crate::prediction::record(stroke, result.is_ok(), scope); return result.map(|()| None); } - Request::OpenKeyboard | Request::OpenMouse => { + Request::OpenKeyboard | Request::OpenMouse | Request::OpenPoint => { crate::prediction::stop(); crate::scan_executor::activate(request) } @@ -282,6 +293,44 @@ fn validate_display(app: &AppHandle, display: Option<&Environment>) -> Result<() #[cfg(test)] mod tests { use super::*; + use crate::scanning::Technique; + + #[test] + fn saving_mode_change_keeps_other_settings_and_reloads() { + let mut config = Config { + speed: 4, + ..Config::default() + }; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap(); + workflow.handle(crate::scanning::Action::OpenMouse); + assert!(PointScan::sync_mode(&mut config, &workflow)); + assert!(!PointScan::sync_mode(&mut config, &workflow)); + let restored: Config = + serde_json::from_slice(&serde_json::to_vec(&config).unwrap()).unwrap(); + assert_eq!(restored.control_mode, crate::point_scan::ControlMode::Mouse); + assert_eq!(restored.speed, 4); + } + + #[test] + fn settings_saves_keep_the_runtime_mode() { + let current = Config { + control_mode: crate::point_scan::ControlMode::Mouse, + ..Config::default() + }; + let mut next = Config { + speed: 5, + ..Config::default() + }; + PointScan::keep_runtime_config(&mut next, ¤t); + assert_eq!(next.control_mode, crate::point_scan::ControlMode::Mouse); + assert_eq!(next.speed, 5); + } #[test] fn typing_preserves_visuals_but_pointer_execution_hides_them() { diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index ccf74aaa..876c5c08 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -14,7 +14,7 @@ pub enum Request { }, OpenKeyboard, OpenMouse, - CloseMouse, + OpenPoint, Keyboard(crate::scan_keyboard::Stroke), MouseMove { dx: i32, @@ -207,6 +207,9 @@ impl Workflow { pub fn mouse_open(&self) -> bool { matches!(self.stage, Stage::Mouse | Stage::MouseMoving) } + pub fn control_mode(&self) -> crate::point_scan::ControlMode { + self.point.config.control_mode + } pub fn set_mouse_area(&mut self, area: Rect, screen: Rect, scale: f64, displays: usize) { self.mouse_area = area; self.point.screen = screen; @@ -272,6 +275,7 @@ impl Workflow { } } fn open_mouse(&mut self) -> Option { + self.point.config.control_mode = crate::point_scan::ControlMode::Mouse; self.stage = Stage::Mouse; self.move_repeat = None; self.mouse = crate::scan_mouse::MousePanel::new( @@ -286,6 +290,13 @@ impl Workflow { self.return_to_mouse = false; Some(Request::OpenMouse) } + fn open_point(&mut self) -> Option { + self.point.config.control_mode = crate::point_scan::ControlMode::Point; + self.reset(); + self.stage = Stage::Point; + self.point.start(); + Some(Request::OpenPoint) + } fn mouse_key(&mut self, key: crate::scan_mouse::Key) -> Option { use crate::scan_mouse::Key; self.mouse.choose(key); @@ -345,10 +356,7 @@ impl Workflow { .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); Some(Request::OpenKeyboard) } - Key::Close => { - self.reset(); - Some(Request::CloseMouse) - } + Key::Close => self.open_point(), Key::More | Key::Movement | Key::Dock => None, } } @@ -495,9 +503,14 @@ impl Technique for Workflow { } } fn start(&mut self) { + let mode = self.point.config.control_mode; self.reset(); - self.stage = Stage::Point; - self.point.start(); + if mode == crate::point_scan::ControlMode::Mouse { + let _ = self.open_mouse(); + } else { + self.stage = Stage::Point; + self.point.start(); + } } fn reset(&mut self) { self.keyboard = crate::scan_keyboard::Keyboard::configured( @@ -547,6 +560,9 @@ impl Technique for Workflow { self.stage != Stage::Executing } fn handle(&mut self, action: Action) -> Option { + if action == Action::OpenPoint { + return self.open_point(); + } if action == Action::OpenMouse { if self.mouse_open() { return None; @@ -828,6 +844,48 @@ mod tests { use super::*; use crate::{point_scan::Config, scanning::Session}; + #[test] + fn select_resumes_saved_mode_and_switching_updates_it() { + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let config = Config { + control_mode: crate::point_scan::ControlMode::Mouse, + ..Config::default() + }; + let mut session = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); + assert_eq!(session.action(Action::Select), None); + assert_eq!( + session.technique.phase(), + Phase::Workflow(WorkflowPhase::Mouse) + ); + assert_eq!( + session.technique.control_mode(), + crate::point_scan::ControlMode::Mouse + ); + assert_eq!(session.action(Action::OpenPoint), Some(Request::OpenPoint)); + assert_eq!( + session.technique.control_mode(), + crate::point_scan::ControlMode::Point + ); + assert!(matches!(session.technique.phase(), Phase::Point(_))); + assert_eq!(session.action(Action::OpenMouse), Some(Request::OpenMouse)); + assert_eq!( + session.technique.mouse_key(crate::scan_mouse::Key::Close), + Some(Request::OpenPoint) + ); + assert_eq!( + session.technique.control_mode(), + crate::point_scan::ControlMode::Point + ); + assert_eq!(session.action(Action::Stop), None); + assert_eq!(session.action(Action::Select), None); + assert!(matches!(session.technique.phase(), Phase::Point(_))); + } + #[test] fn mouse_motion_stops_on_press_and_returns_to_first_row() { let screen = Rect { @@ -939,10 +997,21 @@ mod tests { assert!(workflow.return_to_mouse); workflow.keyboard_closed(); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + assert_eq!( + workflow.control_mode(), + crate::point_scan::ControlMode::Mouse + ); assert_eq!( workflow.mouse_feedback(), Some(crate::input::PointerFeedback::Move) ); + workflow.reset(); + assert_eq!( + workflow.control_mode(), + crate::point_scan::ControlMode::Mouse + ); + workflow.start(); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); } #[test] diff --git a/src-tauri/src/scan_executor.rs b/src-tauri/src/scan_executor.rs index ec05df4f..8b74ca7c 100644 --- a/src-tauri/src/scan_executor.rs +++ b/src-tauri/src/scan_executor.rs @@ -16,7 +16,7 @@ pub fn execute( } match request { Request::Prediction { .. } => Err("Prediction requires the scan controller.".into()), - Request::OpenKeyboard | Request::OpenMouse | Request::CloseMouse => input.release_all(), + Request::OpenKeyboard | Request::OpenMouse | Request::OpenPoint => input.release_all(), Request::MouseMove { dx, dy } => input.move_pointer_pixels(dx, dy).map(|_| ()), Request::MouseMoveAbsolute { x, y } => input.move_pointer_pixels_absolute(x, y).map(|_| ()), Request::MouseClick { right, count } => { @@ -436,7 +436,7 @@ mod tests { .unwrap(); execute(&mut input, Request::MouseScroll { dy: 5 }, true).unwrap(); execute(&mut input, Request::DragStart((10, 20)), true).unwrap(); - execute(&mut input, Request::CloseMouse, true).unwrap(); + execute(&mut input, Request::OpenPoint, true).unwrap(); assert!(!input.has_active_drag()); assert!(input.injector.events.contains(&"relative 4 -2".into())); assert!(input.injector.events.contains(&"move 40 50".into())); diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 338d9bef..5c2cd962 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -153,7 +153,7 @@ impl MousePanel { Movement => "Movement".into(), Keyboard => "Keyboard".into(), Dock => if self.top { "Dock bottom" } else { "Dock top" }.into(), - Close => "Close mouse".into(), + Close => "Switch to Point".into(), } } pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 3318ee07..b2bbb612 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -15,12 +15,14 @@ pub enum Action { Stop, Cancel, OpenKeyboard, + OpenPoint, OpenMouse, } impl Action { pub fn label(self) -> &'static str { match self { Self::OpenKeyboard => "Open keyboard", + Self::OpenPoint => "Open point", Self::OpenMouse => "Open mouse", Self::Select => "Select", Self::Next => "Next", @@ -386,7 +388,10 @@ impl Session { self.reset(); return None; } - if matches!(action, Action::OpenKeyboard | Action::OpenMouse) { + if matches!( + action, + Action::OpenKeyboard | Action::OpenPoint | Action::OpenMouse + ) { let selection = self.technique.handle(action); if selection.is_some() { self.active = true; diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index eb0140fd..c7758e8b 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -19,6 +19,11 @@ pub trait Adapter: Send + Sync + 'static { fn cursor_feedback(_technique: &Self::Technique) -> Option { None } + fn sync_mode(_config: &mut Self::Config, _technique: &Self::Technique) -> bool { + false + } + /// Carries state the runtime owns, such as the saved mode, into settings saved by the UI. + fn keep_runtime_config(_next: &mut Self::Config, _current: &Self::Config) {} fn cursor_action_feedback( _request: &::Selection, ) -> Option { @@ -153,6 +158,26 @@ fn config_path(app: &AppHandle) -> Result Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|_| "Cannot save scanning settings.")?; + } + let bytes = + serde_json::to_vec_pretty(config).map_err(|_| "Cannot encode scanning settings.")?; + let temp = path.with_extension("json.tmp"); + let saved = (|| { + use std::io::Write; + let mut file = std::fs::File::create(&temp)?; + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&temp, path) + })(); + if saved.is_err() { + let _ = std::fs::remove_file(&temp); + } + saved.map_err(|_| "Cannot save scanning settings.".to_string()) +} fn publish(app: &AppHandle) { let _ = app.emit(A::EVENT, app.state::>().view()); } @@ -265,28 +290,20 @@ pub fn interrupt(app: &AppHandle) { /// scanning is off, and the tick loop keeps trying. pub fn configure( app: &AppHandle, - config: A::Config, + mut config: A::Config, ) -> Result::Phase>, String> { A::validate(&config)?; let path = config_path::(app)?; reset_scanner::(app, "Applying scanning settings..."); - let save = || -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - std::fs::write( - &path, - serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string()) - }; - if let Err(error) = save() { - disable::(app, "Scanning settings could not be saved."); - return Err(error); - } let c = app.state::>(); { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + A::keep_runtime_config(&mut config, &d.config); + if let Err(error) = write_config(&path, &config) { + drop(d); + disable::(app, "Scanning settings could not be saved."); + return Err(error); + } d.config = config; d.next_attempt = None; } @@ -426,8 +443,10 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64, re hide_prompt(); let result = (|| -> Result<(), String> { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - if matches!(action, Action::OpenKeyboard | Action::OpenMouse) - || (d.engine.as_ref().is_none_or(|e| !e.active()) && action == Action::Select) + if matches!( + action, + Action::OpenKeyboard | Action::OpenPoint | Action::OpenMouse + ) || (d.engine.as_ref().is_none_or(|e| !e.active()) && action == Action::Select) { let (engine, display) = A::create(app, d.config.clone())?; d.engine = Some(Session::new(engine, A::switches(&d.config).automatic)); @@ -446,6 +465,20 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64, re } A::validate_environment(app, d.display.as_ref())?; let point = d.engine.as_mut().and_then(|e| e.action(action)); + if let Some(mode) = d.engine.as_ref().map(|e| &e.technique) { + let mut config = d.config.clone(); + if A::sync_mode(&mut config, mode) { + // The mode is a preference: keep it in memory and let the action run + // even if the file cannot be written; the next save persists it. + if config_path::(app) + .and_then(|path| write_config(&path, &config)) + .is_err() + { + eprintln!("Switchify could not save the scanning mode."); + } + d.config = config; + } + } d.last_tick = Instant::now(); let display = d.display.clone(); drop(d); @@ -1027,20 +1060,7 @@ pub fn update_point_setting( let mut config = data.config.clone(); setting.apply(&mut config); config.validate()?; - let path = config_path::(app)?; - let save = || -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|_| "Cannot save scanning settings.")?; - } - let temp = path.with_extension("json.tmp"); - std::fs::write( - &temp, - serde_json::to_vec_pretty(&config).map_err(|_| "Cannot encode scanning settings.")?, - ) - .map_err(|_| "Cannot save scanning settings.")?; - std::fs::rename(&temp, &path).map_err(|_| "Cannot save scanning settings.".to_string()) - }; - save()?; + write_config(&config_path::(app)?, &config)?; if let Some(engine) = data.engine.as_mut() { engine.technique.apply_config( config.point(), @@ -1071,6 +1091,30 @@ pub fn restart_point_on_display(app: &AppHandle, next: bool) -> Result<(), Strin Ok(()) } +#[cfg(test)] +mod config_file_tests { + #[test] + fn saving_replaces_the_file_without_leaving_a_temporary_copy() { + let dir = std::env::temp_dir().join(format!( + "switchify-scan-config-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let path = dir.join("point-scan.json"); + super::write_config( + &path, + &serde_json::json!({ "speed": 1, "extra": "long value" }), + ) + .unwrap(); + super::write_config(&path, &serde_json::json!({ "speed": 2 })).unwrap(); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved, serde_json::json!({ "speed": 2 })); + assert!(!path.with_extension("json.tmp").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } +} + #[cfg(test)] mod ownership_tests { #[test] diff --git a/src-tauri/src/switch_gestures.rs b/src-tauri/src/switch_gestures.rs index a2cb6300..e85a8c65 100644 --- a/src-tauri/src/switch_gestures.rs +++ b/src-tauri/src/switch_gestures.rs @@ -34,6 +34,7 @@ impl Gestures { let mut binding = binding.clone(); if countdown && !binding.hold_actions.contains(&Action::OpenKeyboard) + && !binding.hold_actions.contains(&Action::OpenPoint) && !binding.hold_actions.contains(&Action::OpenMouse) && !matches!( binding.press_action, @@ -41,6 +42,7 @@ impl Gestures { | Action::Pause | Action::Cancel | Action::OpenKeyboard + | Action::OpenPoint | Action::OpenMouse ) { diff --git a/src-tauri/src/switches.rs b/src-tauri/src/switches.rs index fe621d94..d642ca3e 100644 --- a/src-tauri/src/switches.rs +++ b/src-tauri/src/switches.rs @@ -133,10 +133,12 @@ mod tests { assert_eq!(serde_json::to_value(&settings).unwrap(), legacy); settings.bindings[0].hold_actions.push(Action::OpenKeyboard); settings.bindings[0].hold_actions.push(Action::OpenMouse); + settings.bindings[0].hold_actions.push(Action::OpenPoint); settings.validate_actions(false).unwrap(); let updated = serde_json::to_value(&settings).unwrap(); assert_eq!(updated["bindings"][0]["holdActions"][5], "openKeyboard"); assert_eq!(updated["bindings"][0]["holdActions"][6], "openMouse"); + assert_eq!(updated["bindings"][0]["holdActions"][7], "openPoint"); assert_eq!( serde_json::from_value::(updated).unwrap(), settings diff --git a/src/App.test.tsx b/src/App.test.tsx index 0a467130..8a9434ce 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -65,7 +65,7 @@ describe("Switchify PC shell", () => { render(); await screen.findByRole("heading", { name: "Ready" }); expect(screen.getByText("1 saved · Select assigned")).toBeInTheDocument(); - expect(screen.getByText("Manual scanning · Line only")).toBeInTheDocument(); + expect(screen.getByText("Select starts Point scanning · Manual")).toBeInTheDocument(); scanning.state = { ...scanning.state!, paused: true }; act(() => receive?.({ ...structuredClone(browserState), bluetooth: "connected" })); expect(screen.getByText("Local scanning is paused while a mobile device is connected.")).toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index 2433f617..1083db53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,7 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi

Input access

{state.accessibility === "required" && }

Saved switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

-

Scanning

{areaOptions(scanning.config, "point").automatic ? "Automatic scanning" : "Manual scanning"} · {scanning.config.mode === "line" ? "Line only" : "Grid then line"}

+

Scanning

Select starts {scanning.config.controlMode === "mouse" ? "Mouse scanning" : "Point scanning"} · {areaOptions(scanning.config, scanning.config.controlMode).automatic ? "Automatic" : "Manual"}

Mobile connection

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" && }
; @@ -407,7 +407,7 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc

Input access

{state.accessibility === "required" && }
} {step === 1 && } - {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select to start scanning. Select chooses a point and opens the action menu.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and menu timing, and Line speed for scanning lines. Holding a switch freezes movement; release runs the action shown.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement. Mobile connection is optional.

} + {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select. It starts the last mode you used. Point scanning chooses a screen location and an action; Mouse scanning moves a visible pointer ring and offers mouse controls.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and panel timing, and Line speed for point scanning lines. Holding a switch freezes movement; release runs the action shown.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement and switch between Point and Mouse. Mobile connection is optional.

} {step === 3 &&

Choose startup behavior

Switchify can start quietly when you sign in, ready for your switches.

} {step === 4 &&
} @@ -778,7 +778,7 @@ export function App() {

{updateNotice}

{view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} />} {view === "switches" &&

Switches

} - {view === "scanning" &&

Scanning

} + {view === "scanning" &&

Scanning

Choose how your switches control the pointer.

} {(view === "mobile" || view === "devices" || view === "profiles") &&

Mobile

{view === "mobile" && void perform(api.disconnectAll)} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index f0fa9f79..addaf328 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -405,11 +405,12 @@ it.each([100, 250, 750, 10000])("adjusts and persists the shared interval from % })); }); -it("keeps method and speed visible while advanced choices stay collapsed", async () => { +it("shows both modes before their settings and keeps advanced panels collapsed", async () => { render(); await screen.findByText(initial.message); - expect(screen.getByRole("group", { name: "Method" })).toBeVisible(); - expect(screen.getByRole("group", { name: "Line speed" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Customise point scanning" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Customise mouse scanning" })).toBeVisible(); + expect(screen.queryByRole("group", { name: "Method" })).toBeNull(); expect(screen.getByRole("checkbox", { name: "Automatic scanning" })).toBeVisible(); expect(screen.queryByRole("radio", { name: "Blue" })).toBeNull(); expect(screen.queryByRole("button", { name: "Customise keyboard" })).toBeNull(); @@ -417,5 +418,22 @@ it("keeps method and speed visible while advanced choices stay collapsed", async expect(screen.getByRole("radio", { name: "Blue" })).toBeChecked(); expect(screen.getByRole("button", { name: "Customise keyboard" })).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); + expect(screen.getByRole("group", { name: "Method" })).toBeVisible(); + expect(screen.getByRole("group", { name: "Line speed" })).toBeVisible(); expect(mocks.invoke.mock.calls.some(([command]) => command === "configure_point_scan")).toBe(false); }); + +it("explains the saved Mouse mode and keeps mode cards keyboard accessible", async () => { + mocks.invoke.mockImplementation((command) => command === "get_point_scan" ? Promise.resolve({ ...initial, config: { ...defaultPointScanConfig, controlMode: "mouse" } }) : Promise.resolve(initial)); + render(); + await screen.findByText(initial.message); + expect(screen.getAllByText("Mouse scanning", { selector: "strong" })).toHaveLength(2); + const mouse = screen.getByRole("button", { name: "Customise mouse scanning" }); + mouse.focus(); + fireEvent.keyDown(mouse, { key: "Enter" }); + fireEvent.click(mouse); + expect(screen.getByRole("heading", { name: "Mouse scanning", level: 2 })).toHaveFocus(); + fireEvent.click(screen.getByRole("button", { name: "Back to scanning settings" })); + expect(screen.getByRole("button", { name: "Customise mouse scanning" })).toHaveFocus(); +}); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index e725d633..94ac4e3b 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; export type ScannerColor = "red" | "green" | "blue" | "yellow" | "white"; export type PointScanConfig = { + controlMode: "point" | "mouse"; scanPreferences?: import("./preferences").ScanPreferences; wordPrediction: boolean; keyboardWaitAfterTyping: boolean; @@ -29,6 +30,7 @@ export type PointScanState = { supported: boolean; }; export const defaultPointScanConfig: PointScanConfig = { + controlMode: "point", wordPrediction: true, keyboardWaitAfterTyping: false, scannerColor: "blue", diff --git a/src/scanning/useSwitches.ts b/src/scanning/useSwitches.ts index b8229e07..18b50f5a 100644 --- a/src/scanning/useSwitches.ts +++ b/src/scanning/useSwitches.ts @@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event"; export const actions = { select: "Select", openKeyboard: "Open keyboard", + openPoint: "Open point", openMouse: "Open mouse", next: "Next", back: "Previous", diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index e727c8bf..829ee7fb 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -33,7 +33,13 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl changedView.current = true; setArea(next); }; - const names = { point: 'Point scanning', menu: 'Menus', keyboard: 'Keyboard', mouse: 'Mouse' }; + const names = { point: 'Point scanning', menu: 'Menus', keyboard: 'Keyboard', mouse: 'Mouse scanning' }; + const descriptions = { + point: 'Choose a place on the screen with a moving line or grid, then choose what to do there.', + mouse: 'Move the pointer with direction controls and a visible ring. Click, drag, scroll, or open the keyboard from the mouse panel.', + menu: 'Choose actions after selecting a point.', + keyboard: 'Choose keys and word suggestions when the scanning keyboard is open.', + }; const disabled = !state?.supported; const settings = config.scanPreferences ?? defaultScanPreferences; const shared = sharedOptions(config); @@ -57,11 +63,13 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl
; }; return
- {area === 'shared' ?

Scanning

:
+ {area === 'shared' ?

Two ways to control your PC

Select starts the last mode you used: {config.controlMode === 'mouse' ? 'Mouse scanning' : 'Point scanning'}. Use Open Point or Open Mouse on a switch to change modes. You can also switch from a scanned panel.

:
-

{names[area]}

Change any value to customise it. Other settings keep following your defaults.

+

{names[area]}

{descriptions[area]} Change any value to customise it; other settings follow the shared defaults.

} - {(area === 'point' || area === 'shared') && <> + {area === 'shared' &&
{(['point', 'mouse'] as const).map(key => )}
} + {area === 'mouse' &&

The ring stays visible while Mouse is open. Select a direction to move; with Repeat mouse movement on, the next switch press stops movement. Pointer speed and repeat controls are in Settings → Controls. Switch to Point returns to screen selection.

} + {area === 'point' && <> {area === 'shared' &&
-

Customise an area

Give an area different settings, or keep using your defaults.

-
{(['point', 'menu', 'keyboard', 'mouse'] as const).map(key => { +

Advanced panel settings

Menus and Keyboard can use different scan settings from the shared defaults.

+
{(['menu', 'keyboard'] as const).map(key => { const options = areaOptions(config, key); - const count = Object.entries(settings[key] ?? {}).filter(([name, value]) => value != null && !(key === 'point' && name === 'pattern')).length; + const count = Object.values(settings[key] ?? {}).filter(value => value != null).length; return