diff --git a/src-tauri/src/display_navigation.rs b/src-tauri/src/display_navigation.rs index 37ed0dc6..10a1dae1 100644 --- a/src-tauri/src/display_navigation.rs +++ b/src-tauri/src/display_navigation.rs @@ -312,6 +312,33 @@ pub fn target_center( )) } +pub fn cycle_center( + cursor: (f64, f64), + displays: &[Display], + next: bool, +) -> Result<(i32, i32), String> { + let current = current_display(cursor, displays).ok_or("No scanning display is available.")?; + if displays.len() < 2 { + return Err("No other display is connected.".into()); + } + let mut ordered = displays.iter().collect::>(); + ordered.sort_by_key(|d| (d.x, d.y, d.width, d.height)); + let index = ordered + .iter() + .position(|d| *d == current) + .ok_or("Display is no longer available.")?; + let index = if next { + (index + 1) % ordered.len() + } else { + (index + ordered.len() - 1) % ordered.len() + }; + let (x, y) = ordered[index].center(); + Ok(( + x.clamp(i32::MIN as i64, i32::MAX as i64) as i32, + y.clamp(i32::MIN as i64, i32::MAX as i64) as i32, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -327,6 +354,28 @@ mod tests { } } + #[test] + fn display_cycle_is_stable_wraps_and_handles_missing_displays() { + let displays = vec![ + display(1920, 0, 1920, 1080), + display(0, 0, 1920, 1080), + display(-1280, 0, 1280, 1024), + ]; + assert_eq!( + cycle_center((10., 10.), &displays, true).unwrap(), + (2880, 540) + ); + assert_eq!( + cycle_center((10., 10.), &displays, false).unwrap(), + (-640, 512) + ); + assert_eq!( + cycle_center((2000., 10.), &displays, true).unwrap(), + (-640, 512) + ); + assert!(cycle_center((0., 0.), &displays[..1], true).is_err()); + assert!(cycle_center((0., 0.), &[], true).is_err()); + } #[test] fn finds_each_direction_and_centers_mixed_resolutions() { let source = display(0, 0, 1920, 1080); diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index de058817..f11517ce 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -11,6 +11,18 @@ use crate::mouse_repeat::RepeatKey; use crate::protocol::MouseButton; use crate::state::{normalize_pointer_scale_percent, AppModel, SwitchBinding, SwitchProfile}; +pub const SCAN_EVENT_MARKER: i64 = 0x53575043; +pub fn injection_settings() -> enigo::Settings { + enigo::Settings { + event_source_user_data: Some(SCAN_EVENT_MARKER), + windows_dw_extra_info: Some(SCAN_EVENT_MARKER as usize), + ..Default::default() + } +} +pub fn own_input(marker: i64) -> bool { + marker == SCAN_EVENT_MARKER +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PointerFeedback { Move, @@ -132,6 +144,10 @@ fn pointer_button(button: MouseButton) -> Button { #[cfg(target_os = "windows")] fn windows_alphanumeric_key(name: &str) -> Option { Some(match name { + "=" => Key::OEMPlus, + "-" => Key::OEMMinus, + "[" => Key::OEM4, + "]" => Key::OEM6, "A" => Key::A, "B" => Key::B, "C" => Key::C, @@ -400,6 +416,29 @@ impl InputInjector for Enigo { .map_err(enigo_error("send the media command")) } fn window(&mut self, action: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + if action == "closeFocused" { + return crate::scan_host::close_foreground_window(); + } + #[cfg(target_os = "windows")] + if matches!(action, "minimizeFocused" | "maximizeFocused") { + use windows_sys::Win32::UI::WindowsAndMessaging::*; + let window = unsafe { GetForegroundWindow() }; + if window.is_null() { + return Err("No foreground window.".into()); + } + let command = if action == "minimizeFocused" { + SW_MINIMIZE + } else if unsafe { IsZoomed(window) } != 0 { + SW_RESTORE + } else { + SW_MAXIMIZE + }; + unsafe { + ShowWindow(window, command); + } + return Ok(()); + } #[cfg(target_os = "windows")] let keys: &[&str] = match action { "switchNext" => &["Alt", "Tab"], @@ -433,6 +472,7 @@ impl InputInjector for Enigo { pub struct DesktopInput { pub(crate) injector: I, + scan_keys: Vec, held_modifiers: HashSet, pending_modifier_releases: HashSet, pending_key_releases: HashSet, @@ -462,6 +502,7 @@ impl DesktopInput { pub fn new(injector: I) -> Self { Self { injector, + scan_keys: vec![], held_modifiers: HashSet::new(), pending_modifier_releases: HashSet::new(), pending_key_releases: HashSet::new(), @@ -1233,9 +1274,48 @@ impl DesktopInput { } } + pub fn scan_chord( + &mut self, + keys: &[&str], + click: Option<((i32, i32), MouseButton, u8)>, + ) -> Result<(), String> { + self.release_scan_keys()?; + let result = (|| { + for key in keys { + self.scan_keys.push((*key).into()); + self.injector.set_key(key, true)?; + } + if let Some((point, button, count)) = click { + self.move_pointer_absolute(point.0, point.1)?; + self.held_button = Some(button); + self.injector.click_pointer(button, count)?; + self.held_button = None; + } + Ok(()) + })(); + let released = self.release_scan_keys(); + result.and(released) + } + fn release_scan_keys(&mut self) -> Result<(), String> { + let mut error = None; + for index in (0..self.scan_keys.len()).rev() { + match self.injector.set_key(&self.scan_keys[index], false) { + Ok(()) => { + self.scan_keys.remove(index); + } + Err(e) => { + error.get_or_insert(e); + } + } + } + error.map_or(Ok(()), Err) + } pub fn release_all(&mut self) -> Result<(), String> { let mut first_error = self.stop_switch_session().err(); self.text_streams.clear(); + if let Err(error) = self.release_scan_keys() { + first_error.get_or_insert(error); + } if let Err(error) = self.release_held_button() { first_error.get_or_insert(error); } diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 7387f700..d981e0e7 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -61,7 +61,7 @@ impl AccessibilityAdapter for SystemAccessibilityAdapter { fn create_input(&mut self) -> Result { Enigo::new(&Settings { open_prompt_to_get_permissions: false, - ..Settings::default() + ..crate::input::injection_settings() }) .map_err(|error| error.to_string()) } @@ -69,7 +69,7 @@ impl AccessibilityAdapter for SystemAccessibilityAdapter { fn request_prompt(&mut self) { let _ = Enigo::new(&Settings { open_prompt_to_get_permissions: true, - ..Settings::default() + ..crate::input::injection_settings() }); } diff --git a/src-tauri/src/modifier_overlay_windows.rs b/src-tauri/src/modifier_overlay_windows.rs index 523f1547..0e96f01f 100644 --- a/src-tauri/src/modifier_overlay_windows.rs +++ b/src-tauri/src/modifier_overlay_windows.rs @@ -14,7 +14,7 @@ use windows::Win32::Graphics::Gdi::{ GetMonitorInfoW, MonitorFromPoint, ReleaseDC, SelectObject, SetBkMode, SetTextColor, AC_SRC_ALPHA, AC_SRC_OVER, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, BLENDFUNCTION, CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_QUALITY, DIB_RGB_COLORS, DT_CENTER, - DT_SINGLELINE, DT_VCENTER, FF_DONTCARE, FW_BOLD, HDC, HGDIOBJ, MONITORINFO, + DT_SINGLELINE, DT_VCENTER, DT_WORDBREAK, FF_DONTCARE, FW_BOLD, HDC, HGDIOBJ, MONITORINFO, MONITOR_DEFAULTTONEAREST, OUT_DEFAULT_PRECIS, TRANSPARENT, }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; @@ -405,7 +405,13 @@ fn present(window: HWND, labels: &[String], layout: &Layout) -> Result<(), Strin None, ); } - present_pixmap_with_text(window, labels, layout, pixmap.data()) + present_pixmap_with_text( + window, + labels, + layout, + pixmap.data(), + labels.iter().any(|label| label.contains('\n')), + ) } fn rounded_rect(rect: Rect, radius: f32) -> Result { @@ -459,6 +465,7 @@ fn present_pixmap_with_text( labels: &[String], layout: &Layout, rgba: &[u8], + wrap: bool, ) -> Result<(), String> { unsafe { let screen = ScreenDc(GetDC(None)); @@ -533,7 +540,11 @@ fn present_pixmap_with_text( memory.0, &mut text, &mut text_rect, - DT_CENTER | DT_VCENTER | DT_SINGLELINE, + if wrap { + DT_CENTER | DT_WORDBREAK + } else { + DT_CENTER | DT_VCENTER | DT_SINGLELINE + }, ); } @@ -603,6 +614,7 @@ pub(crate) fn present_scan_tile( std::slice::from_ref(&tile.text), &layout, pixmap.data(), + true, ) } @@ -615,7 +627,7 @@ pub(crate) fn present_scan_prompt( scale: f64, ) -> Result<(), String> { let padding = (12.0 * scale).round() as i32; - let height = (64.0 * scale).round() as i32; + let height = (if text.contains('\n') { 154.0 } else { 64.0 } * scale).round() as i32; let layout = Layout { x, y, diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 64c79fe9..07713c8e 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -56,7 +56,11 @@ impl Adapter for PointScan { } fn activate(app: &AppHandle, request: Request) -> Result<(), String> { crate::point_scan_ready(app)?; - crate::scan_executor::activate(request) + match request { + Request::Setting(setting) => scanning_runtime::update_point_setting(app, setting), + Request::Display(next) => scanning_runtime::restart_point_on_display(app, next), + request => crate::scan_executor::activate(request), + } } fn cleanup(_app: &AppHandle) -> Result<(), String> { crate::scan_executor::cleanup() diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index ef28eab2..d166b180 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -18,6 +18,12 @@ pub enum Request { dx: i32, dy: i32, }, + Command { + command: crate::scan_menu::Command, + point: Point, + }, + Setting(crate::scan_menu::Setting), + Display(bool), DragStart(Point), DragMove(Point), DragEnd(Point), @@ -65,11 +71,12 @@ pub struct Workflow { point: Engine, stage: Stage, menu: Menu, - parent_menu: Option, + parent_menu: Vec, source: Point, destination: Point, elapsed: u64, pending: Option, + error: Option, } impl Workflow { pub fn new(config: PointSettings, screen: Rect, scale: f64) -> Result { @@ -78,19 +85,32 @@ impl Workflow { point: Engine::new(config, screen, scale)?, stage: Stage::Idle, menu: Menu::new(Kind::Actions, period), - parent_menu: None, + parent_menu: vec![], source: (0, 0), destination: (0, 0), elapsed: 0, pending: None, + error: None, }) } + pub fn apply_config(&mut self, config: PointSettings, restart: bool) { + self.point.config = config; + self.menu.set_period(self.point.config.block_interval_ms); + for menu in &mut self.parent_menu { + menu.set_period(self.point.config.block_interval_ms); + } + if restart { + self.parent_menu.clear(); + self.stage = Stage::Point; + self.point.start(); + } + } fn open(&mut self, kind: Kind) { self.menu = Menu::new(kind, self.point.config.block_interval_ms); self.stage = Stage::Menu; } fn restore_actions(&mut self) { - if let Some(menu) = self.parent_menu.take() { + if let Some(menu) = self.parent_menu.pop() { self.menu = menu; self.menu.restart_interval(); self.stage = Stage::Menu; @@ -100,6 +120,32 @@ impl Workflow { } fn selected(&mut self, item: Item) -> Option { match item { + Item::More | Item::Group(_) => { + let kind = if let Item::Group(kind) = item { + kind + } else { + Kind::More + }; + let next = Menu::new(kind, self.point.config.block_interval_ms); + self.parent_menu + .push(std::mem::replace(&mut self.menu, next)); + } + Item::Command(command) => { + if !command.stays_open() { + self.stage = Stage::Idle; + } + return Some(Request::Command { + command, + point: self.source, + }); + } + Item::Setting(setting) => return Some(Request::Setting(setting)), + Item::Display(next) => return Some(Request::Display(next)), + Item::Pause => self.menu.suspended = true, + Item::Reverse => { + self.menu.handle(Action::Reverse); + } + Item::LeftClick | Item::RightClick | Item::DoubleClick => { self.stage = Stage::Idle; return Some(if item == Item::LeftClick { @@ -114,14 +160,15 @@ impl Workflow { } Item::Scroll | Item::Drag => { let next = Menu::new(Kind::Scroll, self.point.config.block_interval_ms); - self.parent_menu = Some(std::mem::replace(&mut self.menu, next)); + self.parent_menu + .push(std::mem::replace(&mut self.menu, next)); if item == Item::Drag { self.stage = Stage::Destination; self.point.start(); } } Item::NewPoint => { - self.parent_menu = None; + self.parent_menu.clear(); self.stage = Stage::Point; self.point.start(); } @@ -157,6 +204,14 @@ impl Workflow { impl Technique for Workflow { type Selection = Request; type Phase = Phase; + fn execution_failed(&mut self, message: String) { + self.pending = None; + self.stage = Stage::Menu; + self.menu = Menu::new(Kind::Actions, self.point.config.block_interval_ms); + self.parent_menu.clear(); + self.menu.suspended = true; + self.error = Some(message); + } fn start(&mut self) { self.reset(); self.stage = Stage::Point; @@ -164,8 +219,9 @@ impl Technique for Workflow { } fn reset(&mut self) { self.stage = Stage::Idle; - self.parent_menu = None; + self.parent_menu.clear(); self.pending = None; + self.error = None; self.elapsed = 0; self.source = (0, 0); self.destination = (0, 0); @@ -197,6 +253,13 @@ impl Technique for Workflow { } } Stage::Menu => { + if self.error.is_some() { + if action == Action::Select { + self.error = None; + self.menu.restart_interval(); + } + return None; + } if let Some(item) = self.menu.handle(action) { return self.selected(item); } @@ -274,6 +337,14 @@ impl Technique for Workflow { ), _ => Frame::default(), }; + if let Some(error) = &self.error { + frame.tiles.clear(); + if let Some(label) = frame.label.as_mut() { + label.text = format!("{error}\nSelect to return"); + label.rect.height = 100.0 * self.point.units_per_logical_pixel; + label.scale *= 0.65; + } + } if matches!(self.stage, Stage::Menu | Stage::Destination) { let s = self.point.units_per_logical_pixel; frame.strips.extend(outline( @@ -348,6 +419,61 @@ mod tests { s.action(Action::Select) } #[test] + fn nested_back_restores_parent_and_pause_resumes_without_selection() { + let mut w = Workflow::new( + crate::point_scan::Config::default().point(), + Rect { + x: 0., + y: 0., + width: 1280., + height: 720., + }, + 1., + ) + .unwrap(); + w.source = (120, 80); + w.stage = Stage::Menu; + w.selected(Item::More); + w.selected(Item::Group(Kind::Browser)); + w.selected(Item::Group(Kind::Tabs)); + assert_eq!(w.parent_menu.len(), 3); + w.selected(Item::Back); + assert_eq!(w.menu.kind, Kind::Browser); + w.selected(Item::Back); + assert_eq!(w.menu.kind, Kind::More); + w.selected(Item::Pause); + let frame = w.frame(); + w.advance(5000); + assert_eq!(w.frame(), frame); + assert!(w.handle(Action::Select).is_none()); + assert!(!w.menu.suspended); + assert_eq!(w.source, (120, 80)); + } + #[test] + fn command_failure_preserves_point_and_requires_acknowledgement() { + let mut w = Workflow::new( + crate::point_scan::Config::default().point(), + Rect { + x: 0., + y: 0., + width: 1280., + height: 720., + }, + 1., + ) + .unwrap(); + w.source = (120, 80); + w.stage = Stage::Menu; + assert!(matches!( + w.selected(Item::Command(crate::scan_menu::Command::Copy)), + Some(Request::Command { .. }) + )); + w.execution_failed("Action failed.".into()); + assert!(w.frame().label.unwrap().text.contains("Select to return")); + assert!(w.handle(Action::Select).is_none()); + assert_eq!(w.selected(Item::LeftClick), Some(default_click((120, 80)))); + } + #[test] fn scanner_colour_follows_action_scroll_and_drag_menus() { use crate::scanning::ScannerColor::*; for color in [Red, Green, Blue, Yellow, White] { @@ -368,7 +494,7 @@ mod tests { for (column, right, count) in [(0, false, 1), (1, true, 1), (2, false, 2)] { let mut s = session(false); open(&mut s); - assert_eq!(s.frame().tiles.len(), 7); + assert_eq!(s.frame().tiles.len(), 8); assert_eq!( choose(&mut s, 0, column), Some(Request::Click { diff --git a/src-tauri/src/scan_executor.rs b/src-tauri/src/scan_executor.rs index 2979586a..eed4b14a 100644 --- a/src-tauri/src/scan_executor.rs +++ b/src-tauri/src/scan_executor.rs @@ -15,6 +15,10 @@ pub fn execute( ); } match request { + Request::Command { command, point } => execute_command(input, command, point), + Request::Setting(_) | Request::Display(_) => { + Err("Scanning action requires the scan controller.".into()) + } Request::Click { point, right, @@ -52,7 +56,7 @@ pub fn activate(request: Request) -> Result<(), String> { let mut input = slot.borrow_mut(); if input.is_none() { *input = Some(DesktopInput::new( - enigo::Enigo::new(&enigo::Settings::default()) + enigo::Enigo::new(&crate::input::injection_settings()) .map_err(|_| "Scan input could not be initialized.")?, )); } @@ -71,3 +75,312 @@ pub fn cleanup() -> Result<(), String> { .map_or(Ok(()), DesktopInput::release_all) }) } + +pub fn shortcut(command: crate::scan_menu::Command, mac: bool) -> Option> { + use crate::scan_menu::Command::*; + let primary = if mac { "Meta" } else { "Ctrl" }; + Some(match command { + Copy => vec![primary, "C"], + Cut => vec![primary, "X"], + Paste => vec![primary, "V"], + SelectAll => vec![primary, "A"], + Undo => vec![primary, "Z"], + Redo => { + if mac { + vec![primary, "Shift", "Z"] + } else { + vec![primary, "Y"] + } + } + Save => vec![primary, "S"], + Find => vec![primary, "F"], + BrowserBack => { + if mac { + vec![primary, "["] + } else { + vec!["Alt", "ArrowLeft"] + } + } + BrowserForward => { + if mac { + vec![primary, "]"] + } else { + vec!["Alt", "ArrowRight"] + } + } + Reload => vec![primary, "R"], + NewTab => vec![primary, "T"], + CloseTab => vec![primary, "W"], + ReopenTab => vec![primary, "Shift", "T"], + NextTab => vec!["Ctrl", "Tab"], + PreviousTab => vec!["Ctrl", "Shift", "Tab"], + Address => vec![primary, "L"], + ZoomIn => vec![primary, "="], + ZoomOut => vec![primary, "-"], + ZoomReset => vec![primary, "0"], + SwitchNext => vec![if mac { "Meta" } else { "Alt" }, "Tab"], + SwitchPrevious => vec![if mac { "Meta" } else { "Alt" }, "Shift", "Tab"], + Overview => { + if mac { + vec!["Ctrl", "ArrowUp"] + } else { + vec!["Meta", "Tab"] + } + } + Desktop => { + if mac { + vec!["Meta", "F3"] + } else { + vec!["Meta", "D"] + } + } + CloseWindow if !mac => vec!["Alt", "F4"], + Minimize if mac => vec!["Meta", "M"], + Maximize if mac => vec!["Ctrl", "Meta", "F"], + _ => return None, + }) +} +fn execute_command( + input: &mut DesktopInput, + command: crate::scan_menu::Command, + point: (i32, i32), +) -> Result<(), String> { + use crate::scan_menu::Command::*; + if input.has_active_drag() { + return Err("End the active drag before choosing an action.".into()); + } + if let Some(keys) = shortcut(command, cfg!(target_os = "macos")) { + return input.scan_chord(&keys, None); + } + let modifier = match command { + ShiftClick => Some("Shift"), + CtrlClick => Some("Ctrl"), + AltClick => Some("Alt"), + MetaClick => Some("Meta"), + _ => None, + }; + if matches!(command, MiddleClick | TripleClick) || modifier.is_some() { + let keys = modifier.into_iter().collect::>(); + return input.scan_chord( + &keys, + Some(( + point, + if command == MiddleClick { + MouseButton::Middle + } else { + MouseButton::Left + }, + if command == TripleClick { 3 } else { 1 }, + )), + ); + } + match command { + PlayPause => input.injector.media("playPause"), + NextTrack => input.injector.media("nextTrack"), + PreviousTrack => input.injector.media("previousTrack"), + VolumeUp => input.injector.media("volumeUp"), + VolumeDown => input.injector.media("volumeDown"), + Mute => input.injector.media("mute"), + CloseWindow => input.injector.window("closeFocused"), + Minimize => input.injector.window("minimizeFocused"), + Maximize => input.injector.window("maximizeFocused"), + _ => Err("Action is unavailable on this platform.".into()), + } +} +pub fn move_to(point: (i32, i32)) -> Result<(), String> { + INPUT.with(|slot| { + let mut input = slot.borrow_mut(); + if input.is_none() { + *input = Some(DesktopInput::new( + enigo::Enigo::new(&crate::input::injection_settings()) + .map_err(|_| "Scan input could not be initialized.")?, + )); + } + input + .as_mut() + .unwrap() + .move_pointer_absolute(point.0, point.1) + .map(|_| ()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan_menu::Command; + #[derive(Default)] + struct Fake { + events: Vec, + fail_release: bool, + fail_click: bool, + } + impl InputInjector for Fake { + fn inject_text(&mut self, _: &str) -> Result<(), String> { + panic!("No typing keyboard") + } + fn move_pointer(&mut self, _: i32, _: i32) -> Result<(), String> { + panic!("No relative movement") + } + fn move_pointer_absolute(&mut self, x: i32, y: i32) -> Result<(), String> { + self.events.push(format!("move {x} {y}")); + Ok(()) + } + fn click_pointer(&mut self, button: MouseButton, count: u8) -> Result<(), String> { + self.events.push(format!("click {button:?} {count}")); + if self.fail_click { + Err("click failed".into()) + } else { + Ok(()) + } + } + fn set_pointer_button(&mut self, button: MouseButton, down: bool) -> Result<(), String> { + self.events.push(format!("button {button:?} {down}")); + Ok(()) + } + fn scroll(&mut self, dx: i32, dy: i32) -> Result<(), String> { + self.events.push(format!("scroll {dx} {dy}")); + Ok(()) + } + fn set_key(&mut self, key: &str, down: bool) -> Result<(), String> { + self.events.push(format!("key {key} {down}")); + if !down && self.fail_release { + Err("release failed".into()) + } else { + Ok(()) + } + } + fn press_shortcut(&mut self, _: &[String]) -> Result<(), String> { + panic!("Scan chords must retain failed releases") + } + fn media(&mut self, action: &str) -> Result<(), String> { + self.events.push(format!("media {action}")); + Ok(()) + } + fn window(&mut self, action: &str) -> Result<(), String> { + self.events.push(format!("window {action}")); + Ok(()) + } + } + #[test] + fn editing_shortcuts_preserve_focus_and_release_keys_in_reverse_order() { + let mut input = DesktopInput::new(Fake::default()); + execute( + &mut input, + Request::Command { + command: Command::Copy, + point: (100, 200), + }, + true, + ) + .unwrap(); + let primary = if cfg!(target_os = "macos") { + "Meta" + } else { + "Ctrl" + }; + assert_eq!( + input.injector.events, + vec![ + format!("key {primary} true"), + "key C true".into(), + "key C false".into(), + format!("key {primary} false") + ] + ); + } + #[test] + fn modified_click_and_failures_release_every_owned_key_and_button() { + let mut input = DesktopInput::new(Fake { + fail_click: true, + fail_release: true, + ..Default::default() + }); + assert!(execute( + &mut input, + Request::Command { + command: Command::ShiftClick, + point: (100, 200) + }, + true + ) + .is_err()); + assert!(input.has_active_drag()); + input.injector.fail_release = false; + input.release_all().unwrap(); + assert!(!input.has_active_drag()); + assert!(input + .injector + .events + .ends_with(&["key Shift false".into(), "button Left false".into()])); + } + #[test] + fn commands_reject_held_physical_modifiers_without_injection() { + let mut input = DesktopInput::new(Fake::default()); + assert!(execute( + &mut input, + Request::Command { + command: Command::Copy, + point: (0, 0) + }, + false + ) + .is_err()); + assert!(input.injector.events.is_empty()); + } + #[test] + fn platform_mappings_and_input_marker_are_explicit() { + assert_eq!( + shortcut(Command::Redo, true).unwrap(), + ["Meta", "Shift", "Z"] + ); + assert_eq!(shortcut(Command::Redo, false).unwrap(), ["Ctrl", "Y"]); + assert_eq!( + shortcut(Command::CloseWindow, false).unwrap(), + ["Alt", "F4"] + ); + assert_eq!(shortcut(Command::CloseWindow, true), None); + assert!(crate::input::own_input(crate::input::SCAN_EVENT_MARKER)); + assert!(!crate::input::own_input(0)); + assert!(!crate::input::own_input(1234)); + } + #[cfg(target_os = "macos")] + #[test] + fn close_window_uses_native_window_action_without_typing_or_clicking() { + let mut input = DesktopInput::new(Fake::default()); + execute( + &mut input, + Request::Command { + command: Command::CloseWindow, + point: (100, 200), + }, + true, + ) + .unwrap(); + assert_eq!(input.injector.events, ["window closeFocused"]); + } + #[test] + fn media_and_extra_clicks_execute_once() { + let mut input = DesktopInput::new(Fake::default()); + execute( + &mut input, + Request::Command { + command: Command::VolumeUp, + point: (0, 0), + }, + true, + ) + .unwrap(); + assert_eq!(input.injector.events, ["media volumeUp"]); + input.injector.events.clear(); + execute( + &mut input, + Request::Command { + command: Command::TripleClick, + point: (100, 200), + }, + true, + ) + .unwrap(); + assert_eq!(input.injector.events, ["move 100 200", "click Left 3"]); + } +} diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index 462c047d..eadc879a 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -190,7 +190,7 @@ mod platform { opacity: 255, role: crate::scanning::VisualRole::Accent, }])?; - let label = NSTextField::labelWithString(&NSString::from_str(text), mtm); + let label = NSTextField::wrappingLabelWithString(&NSString::from_str(text), mtm); label.setFont(Some(&NSFont::systemFontOfSize(20.0 * scale))); label.setTextColor(Some(&NSColor::whiteColor())); label.setFrame(NSRect::new( @@ -230,7 +230,7 @@ mod platform { let artwork = NSImageView::initWithFrame(NSImageView::alloc(mtm), bounds); artwork.setImage(Some(&image)); view.addSubview(&artwork); - let label = NSTextField::labelWithString(&NSString::from_str(&tile.text), mtm); + let label = NSTextField::wrappingLabelWithString(&NSString::from_str(&tile.text), mtm); label.setFont(Some(&NSFont::boldSystemFontOfSize(15.0 * tile.scale))); label.setTextColor(Some(&NSColor::whiteColor())); label.setAlignment(NSTextAlignment::Center); @@ -314,3 +314,49 @@ pub fn foreground() -> Result { Err("Local scanning is unavailable.".into()) } } + +#[cfg(target_os = "macos")] +pub fn close_foreground_window() -> Result<(), String> { + use core_foundation::{ + base::{CFType, CFTypeRef, TCFType}, + string::{CFString, CFStringRef}, + }; + #[link(name = "ApplicationServices", kind = "framework")] + unsafe extern "C" { + fn AXUIElementCreateApplication(pid: i32) -> CFTypeRef; + fn AXUIElementCopyAttributeValue( + element: CFTypeRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> i32; + fn AXUIElementPerformAction(element: CFTypeRef, action: CFStringRef) -> i32; + } + fn attribute(element: &CFType, name: &str) -> Result { + let name = CFString::new(name); + let mut value = std::ptr::null(); + let result = unsafe { + AXUIElementCopyAttributeValue( + element.as_CFTypeRef(), + name.as_concrete_TypeRef(), + &mut value, + ) + }; + if result != 0 || value.is_null() { + return Err("The foreground window's close button is unavailable.".into()); + } + Ok(unsafe { CFType::wrap_under_create_rule(value) }) + } + let application = unsafe { AXUIElementCreateApplication(foreground()? as i32) }; + if application.is_null() { + return Err("No foreground application is available.".into()); + } + let application = unsafe { CFType::wrap_under_create_rule(application) }; + let window = attribute(&application, "AXFocusedWindow")?; + let button = attribute(&window, "AXCloseButton")?; + let action = CFString::new("AXPress"); + if unsafe { AXUIElementPerformAction(button.as_CFTypeRef(), action.as_concrete_TypeRef()) } != 0 + { + return Err("The foreground window could not be closed.".into()); + } + Ok(()) +} diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index 76059d02..4b9b23b0 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -5,6 +5,13 @@ use crate::{ }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Item { + More, + Group(Kind), + Command(Command), + Setting(Setting), + Display(bool), + Pause, + Reverse, LeftClick, RightClick, DoubleClick, @@ -24,6 +31,14 @@ pub enum Item { impl Item { pub fn label(self) -> &'static str { match self { + Self::More => "More", + Self::Group(kind) => kind.label(), + Self::Command(command) => command.label(), + Self::Setting(setting) => setting.label(), + Self::Display(true) => "Next display", + Self::Display(false) => "Previous display", + Self::Pause => "Pause scanning", + Self::Reverse => "Reverse direction", Self::LeftClick => "Left click", Self::RightClick => "Right click", Self::DoubleClick => "Double click", @@ -35,7 +50,7 @@ impl Item { Self::Down => "Down", Self::Left => "Left", Self::Right => "Right", - Self::Back => "Back to actions", + Self::Back => "Back", Self::DragHere => "Drag here", Self::DestinationAgain => "New destination", Self::CancelDrag => "Cancel drag", @@ -44,6 +59,16 @@ impl Item { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { + More, + Mouse, + Editing, + Windows, + Browser, + Tabs, + Zoom, + Media, + Displays, + Scanning, Actions, Scroll, ConfirmDrag, @@ -60,16 +85,7 @@ pub struct Menu { } impl Menu { pub fn new(kind: Kind, period: u64) -> Self { - use Item::*; - let rows = match kind { - Kind::Actions => vec![ - vec![LeftClick, RightClick, DoubleClick], - vec![Scroll, Drag], - vec![NewPoint, Cancel], - ], - Kind::Scroll => vec![vec![Up, Down], vec![Left, Right], vec![Back]], - Kind::ConfirmDrag => vec![vec![DragHere, DestinationAgain, CancelDrag]], - }; + let rows = kind.rows(); let nav = Navigator::new( rows.iter() .map(|row| Node::Branch(row.iter().copied().map(Node::Leaf).collect())) @@ -86,6 +102,10 @@ impl Menu { suspended: false, } } + pub fn set_period(&mut self, period: u64) { + self.period = period; + self.restart_interval(); + } pub fn restart_interval(&mut self) { self.interval.reset(); self.cycles = 0; @@ -169,6 +189,7 @@ impl Menu { Kind::Actions => "Choose an action", Kind::Scroll => "Scroll at selected point", Kind::ConfirmDrag => "Confirm drag", + kind => kind.label(), } }; frame.label = Some(FrameLabel { @@ -223,10 +244,344 @@ fn place(point: (i32, i32), screen: Rect, width: f64, height: f64, gap: f64) -> } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + MiddleClick, + TripleClick, + ShiftClick, + CtrlClick, + AltClick, + MetaClick, + Copy, + Cut, + Paste, + SelectAll, + Undo, + Redo, + Save, + Find, + SwitchNext, + SwitchPrevious, + Overview, + Desktop, + Minimize, + Maximize, + CloseWindow, + BrowserBack, + BrowserForward, + Reload, + NewTab, + CloseTab, + ReopenTab, + NextTab, + PreviousTab, + Address, + ZoomIn, + ZoomOut, + ZoomReset, + PlayPause, + NextTrack, + PreviousTrack, + VolumeUp, + VolumeDown, + Mute, +} +impl Command { + pub fn label(self) -> &'static str { + match self { + Self::MiddleClick => "Middle click", + Self::TripleClick => "Triple click", + Self::ShiftClick => "Shift-click", + Self::CtrlClick => "Ctrl-click", + Self::AltClick => { + if cfg!(target_os = "macos") { + "Option-click" + } else { + "Alt-click" + } + } + Self::MetaClick => { + if cfg!(target_os = "macos") { + "Command-click" + } else { + "Windows-click" + } + } + Self::Copy => "Copy", + Self::Cut => "Cut", + Self::Paste => "Paste", + Self::SelectAll => "Select all", + Self::Undo => "Undo", + Self::Redo => "Redo", + Self::Save => "Save", + Self::Find => "Find", + Self::SwitchNext => "Switch app forward", + Self::SwitchPrevious => "Switch app backward", + Self::Overview => "App overview", + Self::Desktop => "Show desktop", + Self::Minimize => "Minimize", + Self::Maximize => { + if cfg!(target_os = "macos") { + "Toggle full screen" + } else { + "Maximize / restore" + } + } + Self::CloseWindow => "Close window", + Self::BrowserBack => "Back", + Self::BrowserForward => "Forward", + Self::Reload => "Reload", + Self::NewTab => "New tab", + Self::CloseTab => "Close tab", + Self::ReopenTab => "Reopen closed tab", + Self::NextTab => "Next tab", + Self::PreviousTab => "Previous tab", + Self::Address => "Address bar", + Self::ZoomIn => "Zoom in", + Self::ZoomOut => "Zoom out", + Self::ZoomReset => "Reset zoom", + Self::PlayPause => "Play / pause", + Self::NextTrack => "Next track", + Self::PreviousTrack => "Previous track", + Self::VolumeUp => "Volume up", + Self::VolumeDown => "Volume down", + Self::Mute => "Mute", + } + } + pub fn stays_open(self) -> bool { + matches!( + self, + Self::PlayPause + | Self::NextTrack + | Self::PreviousTrack + | Self::VolumeUp + | Self::VolumeDown + | Self::Mute + ) + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Setting { + FasterScan, + SlowerScan, + FasterLine, + SlowerLine, + LineMode, + GridMode, +} +impl Setting { + pub fn label(self) -> &'static str { + match self { + Self::FasterScan => "Faster auto scan", + Self::SlowerScan => "Slower auto scan", + Self::FasterLine => "Faster lines", + Self::SlowerLine => "Slower lines", + Self::LineMode => "Line only", + Self::GridMode => "Grid then line", + } + } + pub fn apply(self, config: &mut crate::point_scan::Config) { + const RATES: &[u64] = &[250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000]; + match self { + Self::FasterScan => { + config.block_interval_ms = RATES + .iter() + .rev() + .copied() + .find(|v| *v < config.block_interval_ms) + .unwrap_or(RATES[0]) + } + Self::SlowerScan => { + config.block_interval_ms = RATES + .iter() + .copied() + .find(|v| *v > config.block_interval_ms) + .unwrap_or(5000) + } + Self::FasterLine => config.speed = (config.speed + 1).min(4), + Self::SlowerLine => config.speed = config.speed.saturating_sub(1), + Self::LineMode => config.mode = crate::point_scan::Mode::Line, + Self::GridMode => config.mode = crate::point_scan::Mode::Grid, + } + } +} +impl Kind { + pub fn label(self) -> &'static str { + match self { + Self::Actions => "Choose an action", + Self::Scroll => "Scroll", + Self::ConfirmDrag => "Confirm drag", + Self::More => "More actions", + Self::Mouse => "Mouse", + Self::Editing => "Editing", + Self::Windows => "Apps and windows", + Self::Browser => "Browser", + Self::Tabs => "Tabs", + Self::Zoom => "Zoom", + Self::Media => "Media", + Self::Displays => "Displays", + Self::Scanning => "Scanning", + } + } + fn rows(self) -> Vec> { + use self::Command as C; + use Item::*; + let items = match self { + Self::Actions => { + return vec![ + vec![LeftClick, RightClick, DoubleClick], + vec![Scroll, Drag, More], + vec![NewPoint, Cancel], + ] + } + Self::Scroll => return vec![vec![Up, Down], vec![Left, Right], vec![Back]], + Self::ConfirmDrag => return vec![vec![DragHere, DestinationAgain, CancelDrag]], + Self::More => vec![ + Group(Self::Mouse), + Group(Self::Editing), + Group(Self::Windows), + Group(Self::Browser), + Group(Self::Media), + Group(Self::Displays), + Group(Self::Scanning), + Back, + ], + Self::Mouse => vec![ + Command(C::MiddleClick), + Command(C::TripleClick), + Command(C::ShiftClick), + Command(C::CtrlClick), + Command(C::AltClick), + Command(C::MetaClick), + Back, + ], + Self::Editing => vec![ + Command(C::Copy), + Command(C::Cut), + Command(C::Paste), + Command(C::SelectAll), + Command(C::Undo), + Command(C::Redo), + Command(C::Save), + Command(C::Find), + Back, + ], + Self::Windows => vec![ + Command(C::SwitchNext), + Command(C::SwitchPrevious), + Command(C::Overview), + Command(C::Desktop), + Command(C::Minimize), + Command(C::Maximize), + Command(C::CloseWindow), + Back, + ], + Self::Browser => vec![ + Command(C::BrowserBack), + Command(C::BrowserForward), + Command(C::Reload), + Group(Self::Tabs), + Group(Self::Zoom), + Command(C::Address), + Back, + ], + Self::Tabs => vec![ + Command(C::NewTab), + Command(C::CloseTab), + Command(C::ReopenTab), + Command(C::NextTab), + Command(C::PreviousTab), + Back, + ], + Self::Zoom => vec![ + Command(C::ZoomIn), + Command(C::ZoomOut), + Command(C::ZoomReset), + Back, + ], + Self::Media => vec![ + Command(C::PlayPause), + Command(C::NextTrack), + Command(C::PreviousTrack), + Command(C::VolumeUp), + Command(C::VolumeDown), + Command(C::Mute), + Back, + ], + Self::Displays => vec![Display(true), Display(false), Back], + Self::Scanning => vec![ + Pause, + Reverse, + Setting(self::Setting::FasterScan), + Setting(self::Setting::SlowerScan), + Setting(self::Setting::FasterLine), + Setting(self::Setting::SlowerLine), + Setting(self::Setting::LineMode), + Setting(self::Setting::GridMode), + Back, + ], + }; + items.chunks(3).map(|row| row.to_vec()).collect() + } +} + #[cfg(test)] mod tests { use super::*; #[test] + fn every_submenu_is_reachable_and_fits_a_three_by_three_grid() { + let mut pending = vec![Kind::Actions]; + let mut visited = vec![]; + let mut commands = vec![]; + while let Some(kind) = pending.pop() { + if visited.contains(&kind) { + continue; + } + visited.push(kind); + let rows = kind.rows(); + assert!(rows.len() <= 3); + assert!(rows.iter().all(|r| r.len() <= 3)); + if kind != Kind::Actions { + assert!(rows.iter().flatten().any(|i| *i == Item::Back)); + } + for item in rows.into_iter().flatten() { + match item { + Item::More => pending.push(Kind::More), + Item::Group(k) => pending.push(k), + Item::Command(c) => commands.push(c), + _ => {} + } + } + } + assert_eq!(commands.len(), 39); + assert!(visited.contains(&Kind::Scanning)); + assert!(visited.contains(&Kind::Displays)); + assert!(visited.contains(&Kind::Tabs)); + assert!(visited.contains(&Kind::Zoom)); + } + #[test] + fn settings_step_presets_and_clamp_at_limits() { + let mut c = crate::point_scan::Config { + block_interval_ms: 650, + ..Default::default() + }; + Setting::FasterScan.apply(&mut c); + assert_eq!(c.block_interval_ms, 500); + Setting::SlowerScan.apply(&mut c); + assert_eq!(c.block_interval_ms, 750); + for _ in 0..20 { + Setting::FasterScan.apply(&mut c); + Setting::SlowerLine.apply(&mut c); + } + assert_eq!((c.block_interval_ms, c.speed), (250, 0)); + for _ in 0..20 { + Setting::SlowerScan.apply(&mut c); + Setting::FasterLine.apply(&mut c); + } + assert_eq!((c.block_interval_ms, c.speed), (5000, 4)); + c.validate().unwrap(); + } + #[test] fn grid_keeps_square_columns_and_highlights_the_current_row_or_item() { let screen = Rect { x: 0.0, diff --git a/src-tauri/src/scan_tile.rs b/src-tauri/src/scan_tile.rs index 8f9b9340..2e1e24b1 100644 --- a/src-tauri/src/scan_tile.rs +++ b/src-tauri/src/scan_tile.rs @@ -81,6 +81,9 @@ pub fn bitmap(tile: &FrameTile) -> Result { lines(&[(62., 46.), (106., 90.)]); lines(&[(106., 46.), (62., 90.)]); } + More | Group(_) | Command(_) | Setting(_) | Display(_) | Pause | Reverse => { + artwork(&mut path, tile.icon); + } DragHere => { lines(&[(55., 69.), (75., 89.), (113., 47.)]); } @@ -143,6 +146,431 @@ pub fn bitmap(tile: &FrameTile) -> Result { Ok(pixmap) } +fn line(path: &mut PathBuilder, points: &[(f32, f32)]) { + path.move_to(points[0].0, points[0].1); + for &(x, y) in &points[1..] { + path.line_to(x, y); + } +} +fn rect(path: &mut PathBuilder, x: f32, y: f32, w: f32, h: f32) { + path.push_rect(tiny_skia::Rect::from_xywh(x, y, w, h).unwrap()); +} +fn arrow(path: &mut PathBuilder, x: f32, y: f32, direction: f32) { + line(path, &[(x - 16. * direction, y), (x + 16. * direction, y)]); + line( + path, + &[ + (x + 5. * direction, y - 11.), + (x + 16. * direction, y), + (x + 5. * direction, y + 11.), + ], + ); +} +fn plus(path: &mut PathBuilder, x: f32, y: f32, positive: bool) { + line(path, &[(x - 9., y), (x + 9., y)]); + if positive { + line(path, &[(x, y - 9.), (x, y + 9.)]); + } +} +fn cross(path: &mut PathBuilder, x: f32, y: f32) { + line(path, &[(x - 8., y - 8.), (x + 8., y + 8.)]); + line(path, &[(x - 8., y + 8.), (x + 8., y - 8.)]); +} +fn monitor(path: &mut PathBuilder) { + rect(path, 49., 39., 70., 47.); + line(path, &[(84., 86.), (84., 98.)]); + line(path, &[(67., 99.), (101., 99.)]); +} +fn window(path: &mut PathBuilder) { + rect(path, 49., 40., 70., 59.); + line(path, &[(49., 54.), (119., 54.)]); + path.push_circle(57., 47., 1.); +} +fn tab(path: &mut PathBuilder) { + line( + path, + &[ + (48., 98.), + (48., 49.), + (55., 49.), + (61., 39.), + (83., 39.), + (89., 49.), + (120., 49.), + (120., 98.), + (48., 98.), + ], + ); + line(path, &[(49., 59.), (119., 59.)]); +} +fn mouse(path: &mut PathBuilder, x: f32) { + path.move_to(x, 58.); + path.cubic_to(x, 30., x + 38., 30., x + 38., 58.); + path.line_to(x + 38., 80.); + path.cubic_to(x + 38., 108., x, 108., x, 80.); + path.close(); + line(path, &[(x, 61.), (x + 38., 61.)]); + line(path, &[(x + 19., 39.), (x + 19., 59.)]); +} +fn return_arrow(path: &mut PathBuilder, forward: bool) { + let (start, end, direction) = if forward { + (52., 108., 1.) + } else { + (116., 60., -1.) + }; + path.move_to(start, 94.); + path.cubic_to(start, 57., end, 57., end, 63.); + line( + path, + &[ + (end - 13. * direction, 48.), + (end + 3. * direction, 63.), + (end - 13. * direction, 77.), + ], + ); +} +fn artwork(path: &mut PathBuilder, item: Item) { + use crate::scan_menu::{Command as C, Kind as K, Setting as S}; + match item { + Item::More => { + for x in [60., 84., 108.] { + path.push_circle(x, 68., 5.); + } + } + Item::Group(kind) => match kind { + K::Mouse => mouse(path, 65.), + K::Editing => { + line( + path, + &[ + (54., 95.), + (59., 76.), + (102., 33.), + (116., 47.), + (73., 90.), + (54., 95.), + ], + ); + line(path, &[(59., 76.), (73., 90.)]); + line(path, &[(95., 40.), (109., 54.)]); + line(path, &[(85., 99.), (118., 99.)]); + } + K::Windows => { + rect(path, 46., 36., 57., 43.); + rect(path, 65., 58., 57., 43.); + line(path, &[(65., 70.), (122., 70.)]); + } + K::Browser => { + path.push_circle(84., 68., 32.); + path.move_to(84., 36.); + path.cubic_to(59., 50., 59., 86., 84., 100.); + path.move_to(84., 36.); + path.cubic_to(109., 50., 109., 86., 84., 100.); + line(path, &[(52., 68.), (116., 68.)]); + } + K::Media => { + path.push_circle(84., 68., 33.); + line(path, &[(76., 51.), (101., 68.), (76., 85.), (76., 51.)]); + } + K::Displays => { + monitor(path); + rect(path, 42., 32., 70., 47.); + } + K::Scanning => { + rect(path, 51., 35., 66., 66.); + line(path, &[(73., 35.), (73., 101.)]); + line(path, &[(95., 35.), (95., 101.)]); + line(path, &[(51., 57.), (117., 57.)]); + line(path, &[(51., 79.), (117., 79.)]); + path.push_circle(84., 68., 5.); + } + K::Tabs => { + tab(path); + line(path, &[(96., 39.), (119., 39.), (126., 49.)]); + } + K::Zoom => { + path.push_circle(78., 61., 24.); + line(path, &[(95., 79.), (117., 101.)]); + } + K::More | K::Actions | K::Scroll | K::ConfirmDrag => { + unreachable!("Not a grouped menu tile") + } + }, + Item::Command(command) => match command { + C::MiddleClick => { + mouse(path, 65.); + rect(path, 80., 44., 8., 12.); + } + C::TripleClick => { + mouse(path, 57.); + for y in [43., 58., 73.] { + line(path, &[(108., y), (119., y)]); + } + } + C::ShiftClick | C::CtrlClick | C::AltClick | C::MetaClick => { + mouse(path, 44.); + match command { + C::ShiftClick => line( + path, + &[ + (99., 79.), + (99., 63.), + (91., 63.), + (109., 45.), + (127., 63.), + (119., 63.), + (119., 79.), + (99., 79.), + ], + ), + C::CtrlClick => line(path, &[(94., 70.), (109., 52.), (124., 70.)]), + C::AltClick => { + line(path, &[(91., 51.), (103., 51.), (116., 81.), (129., 81.)]); + line(path, &[(111., 51.), (129., 51.)]); + } + C::MetaClick if cfg!(target_os = "macos") => { + rect(path, 101., 57., 16., 16.); + for (x, y) in [(97., 53.), (121., 53.), (97., 77.), (121., 77.)] { + path.push_circle(x, y, 4.); + } + } + C::MetaClick => { + for (x, y) in [(94., 49.), (113., 49.), (94., 68.), (113., 68.)] { + rect(path, x, y, 14., 14.); + } + } + _ => unreachable!(), + } + } + C::Copy => { + rect(path, 48., 36., 48., 52.); + rect(path, 70., 54., 48., 52.); + } + C::Cut => { + path.push_circle(60., 86., 11.); + path.push_circle(108., 86., 11.); + line(path, &[(68., 78.), (113., 37.)]); + line(path, &[(100., 78.), (55., 37.)]); + } + C::Paste => { + rect(path, 55., 42., 58., 60.); + rect(path, 71., 34., 26., 16.); + line(path, &[(70., 66.), (99., 66.)]); + line(path, &[(70., 80.), (94., 80.)]); + } + C::SelectAll => { + for (x, y, dx, dy) in [ + (50., 37., 16., 16.), + (118., 37., -16., 16.), + (50., 99., 16., -16.), + (118., 99., -16., -16.), + ] { + line(path, &[(x + dx, y), (x, y), (x, y + dy)]); + } + for y in [53., 68., 83.] { + line(path, &[(68., y), (101., y)]); + } + } + C::Undo | C::Redo => return_arrow(path, command == C::Redo), + C::Save => { + line( + path, + &[ + (54., 36.), + (104., 36.), + (116., 48.), + (116., 101.), + (54., 101.), + (54., 36.), + ], + ); + rect(path, 68., 36., 30., 22.); + rect(path, 66., 76., 38., 25.); + } + C::Find => { + path.push_circle(76., 60., 23.); + line(path, &[(93., 77.), (117., 101.)]); + line(path, &[(67., 56.), (85., 56.)]); + line(path, &[(67., 65.), (79., 65.)]); + } + C::SwitchNext | C::SwitchPrevious => { + rect(path, 48., 36., 46., 38.); + rect(path, 73., 54., 46., 38.); + arrow( + path, + 84., + 98., + if command == C::SwitchNext { 1. } else { -1. }, + ); + } + C::Overview => { + rect(path, 48., 37., 30., 27.); + rect(path, 88., 37., 32., 40.); + rect(path, 48., 74., 30., 27.); + rect(path, 88., 87., 32., 14.); + } + C::Desktop => { + monitor(path); + rect(path, 59., 48., 9., 9.); + rect(path, 59., 66., 9., 9.); + line(path, &[(77., 78.), (108., 78.)]); + } + C::Minimize => { + window(path); + line(path, &[(71., 85.), (99., 85.)]); + } + C::Maximize => { + for (x, y, dx, dy) in [ + (52., 39., 18., 18.), + (116., 39., -18., 18.), + (52., 99., 18., -18.), + (116., 99., -18., -18.), + ] { + line(path, &[(x + dx, y), (x, y), (x, y + dy)]); + line(path, &[(x, y), (x + dx, y + dy)]); + } + } + C::CloseWindow => { + window(path); + cross(path, 84., 77.); + } + C::BrowserBack | C::BrowserForward => { + window(path); + arrow( + path, + 84., + 77., + if command == C::BrowserForward { + 1. + } else { + -1. + }, + ); + } + C::Reload => { + path.move_to(111., 52.); + path.cubic_to(88., 24., 48., 43., 54., 76.); + path.cubic_to(60., 105., 98., 110., 115., 85.); + line(path, &[(112., 35.), (112., 54.), (94., 54.)]); + } + C::NewTab | C::CloseTab | C::ReopenTab | C::NextTab | C::PreviousTab => { + tab(path); + match command { + C::NewTab => plus(path, 84., 79., true), + C::CloseTab => cross(path, 84., 79.), + C::NextTab | C::PreviousTab => { + arrow(path, 84., 79., if command == C::NextTab { 1. } else { -1. }) + } + C::ReopenTab => { + path.move_to(103., 89.); + path.cubic_to(103., 69., 80., 68., 72., 77.); + line(path, &[(72., 66.), (72., 79.), (85., 79.)]); + } + _ => unreachable!(), + } + } + C::Address => { + rect(path, 44., 48., 80., 38.); + path.push_circle(60., 67., 5.); + line(path, &[(79., 57.), (79., 77.)]); + line(path, &[(74., 57.), (84., 57.)]); + line(path, &[(74., 77.), (84., 77.)]); + line(path, &[(96., 67.), (111., 67.)]); + } + C::ZoomIn | C::ZoomOut | C::ZoomReset => { + path.push_circle(76., 60., 25.); + line(path, &[(94., 79.), (116., 101.)]); + if command == C::ZoomReset { + line(path, &[(67., 52.), (67., 68.)]); + line(path, &[(85., 52.), (85., 68.)]); + path.push_circle(76., 56., 1.); + path.push_circle(76., 64., 1.); + } else { + plus(path, 76., 60., command == C::ZoomIn); + } + } + C::PlayPause => { + line(path, &[(52., 43.), (84., 68.), (52., 93.), (52., 43.)]); + line(path, &[(101., 44.), (101., 92.)]); + line(path, &[(117., 44.), (117., 92.)]); + } + C::NextTrack | C::PreviousTrack => { + let d = if command == C::NextTrack { 1. } else { -1. }; + line( + path, + &[ + (84. - 24. * d, 44.), + (84. + 12. * d, 68.), + (84. - 24. * d, 92.), + (84. - 24. * d, 44.), + ], + ); + line(path, &[(84. + 26. * d, 44.), (84. + 26. * d, 92.)]); + } + C::VolumeUp | C::VolumeDown | C::Mute => { + line( + path, + &[ + (47., 57.), + (60., 57.), + (81., 40.), + (81., 96.), + (60., 79.), + (47., 79.), + (47., 57.), + ], + ); + if command == C::Mute { + cross(path, 110., 68.); + } else { + plus(path, 110., 68., command == C::VolumeUp); + } + } + }, + Item::Display(next) => { + monitor(path); + arrow(path, 84., 63., if next { 1. } else { -1. }); + } + Item::Pause => { + rect(path, 62., 42., 13., 52.); + rect(path, 94., 42., 13., 52.); + } + Item::Reverse => { + arrow(path, 84., 51., -1.); + arrow(path, 84., 86., 1.); + } + Item::Setting(setting) => match setting { + S::FasterScan | S::SlowerScan => { + path.push_circle(74., 68., 27.); + line(path, &[(74., 49.), (74., 68.), (88., 76.)]); + line(path, &[(66., 32.), (82., 32.)]); + plus(path, 117., 43., setting == S::FasterScan); + } + S::FasterLine | S::SlowerLine => { + line(path, &[(57., 38.), (57., 99.)]); + line(path, &[(68., 88.), (97., 88.)]); + line(path, &[(84., 77.), (97., 88.), (84., 99.)]); + plus(path, 106., 49., setting == S::FasterLine); + } + S::LineMode => { + rect(path, 50., 35., 68., 66.); + line(path, &[(79., 36.), (79., 100.)]); + line(path, &[(51., 74.), (117., 74.)]); + path.push_circle(79., 74., 6.); + } + S::GridMode => { + rect(path, 48., 36., 72., 64.); + line(path, &[(72., 36.), (72., 100.)]); + line(path, &[(96., 36.), (96., 100.)]); + line(path, &[(48., 57.), (120., 57.)]); + line(path, &[(48., 79.), (120., 79.)]); + rect(path, 75., 60., 18., 16.); + } + }, + _ => unreachable!("Legacy tile artwork is drawn separately"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -174,6 +602,7 @@ mod tests { } #[test] fn every_action_has_distinct_artwork_and_selection_changes_the_background() { + use crate::scan_menu::{Command as C, Kind as K, Setting as S}; use Item::*; let mut images = Vec::new(); for icon in [ @@ -189,6 +618,65 @@ mod tests { Left, Right, DragHere, + More, + Group(K::Mouse), + Group(K::Editing), + Group(K::Windows), + Group(K::Browser), + Group(K::Media), + Group(K::Displays), + Group(K::Scanning), + Group(K::Tabs), + Group(K::Zoom), + Command(C::MiddleClick), + Command(C::TripleClick), + Command(C::ShiftClick), + Command(C::CtrlClick), + Command(C::AltClick), + Command(C::MetaClick), + Command(C::Copy), + Command(C::Cut), + Command(C::Paste), + Command(C::SelectAll), + Command(C::Undo), + Command(C::Redo), + Command(C::Save), + Command(C::Find), + Command(C::SwitchNext), + Command(C::SwitchPrevious), + Command(C::Overview), + Command(C::Desktop), + Command(C::Minimize), + Command(C::Maximize), + Command(C::CloseWindow), + Command(C::BrowserBack), + Command(C::BrowserForward), + Command(C::Reload), + Command(C::NewTab), + Command(C::CloseTab), + Command(C::ReopenTab), + Command(C::NextTab), + Command(C::PreviousTab), + Command(C::Address), + Command(C::ZoomIn), + Command(C::ZoomOut), + Command(C::ZoomReset), + Command(C::PlayPause), + Command(C::NextTrack), + Command(C::PreviousTrack), + Command(C::VolumeUp), + Command(C::VolumeDown), + Command(C::Mute), + Display(true), + Display(false), + Pause, + Reverse, + Setting(S::FasterScan), + Setting(S::SlowerScan), + Setting(S::FasterLine), + Setting(S::SlowerLine), + Setting(S::LineMode), + Setting(S::GridMode), ] { let mut tile = FrameTile { color: Default::default(), @@ -204,7 +692,10 @@ mod tests { selected: false, }; let normal = bitmap(&tile).unwrap(); - assert!(images.iter().all(|previous| previous != normal.data())); + assert!( + images.iter().all(|previous| previous != normal.data()), + "Duplicate artwork: {icon:?}" + ); images.push(normal.data().to_vec()); tile.selected = true; let selected = bitmap(&tile).unwrap(); diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index fcdc2fed..0cfc6704 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -226,6 +226,7 @@ pub const MAX_SCAN_CYCLES: usize = 3; pub trait Technique { type Selection; type Phase: Clone + Default + PartialEq + Serialize; + fn execution_failed(&mut self, _message: String) {} fn start(&mut self); fn advance(&mut self, elapsed_ms: u64); fn handle(&mut self, action: Action) -> Option; @@ -271,6 +272,11 @@ impl Session { paused: false, } } + pub fn execution_failed(&mut self, message: String) { + self.active = true; + self.paused = false; + self.technique.execution_failed(message); + } pub fn active(&self) -> bool { self.active } @@ -299,10 +305,11 @@ impl Session { if !self.technique.pausable() { self.paused = false; } - if (selection.is_some() && self.technique.complete_on_selection()) - || self.technique.finished() - { + if selection.is_some() && self.technique.complete_on_selection() { self.reset(); + } else if self.technique.finished() { + self.active = false; + self.paused = false; } selection } @@ -320,7 +327,8 @@ impl Session { pub fn take_selection(&mut self) -> Option { let selection = self.technique.take_selection(); if self.technique.finished() { - self.reset(); + self.active = false; + self.paused = false; } selection } diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 13aab09f..c10e719d 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -399,7 +399,21 @@ fn dispatch( host.hide(); } }); - A::activate(app, request) + if let Err(error) = A::activate(app, request) { + A::cleanup(app)?; + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.message = error.clone(); + if let Some(engine) = d.engine.as_mut() { + engine.execution_failed(error); + } + } + { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + if d.engine.as_ref().is_some_and(|e| !e.active()) { + d.display = None; + } + } + Ok(()) } fn render_tiles(tiles: &[crate::scanning::FrameTile]) -> Result<(), String> { TILES.with(|slot| { @@ -711,6 +725,60 @@ fn show_prompt( }) } +pub fn update_point_setting( + app: &AppHandle, + setting: crate::scan_menu::Setting, +) -> Result<(), String> { + use crate::point_scan_runtime::PointScan; + let controller = app.state::>(); + let mut data = controller.data.lock().unwrap_or_else(|p| p.into_inner()); + 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()?; + if let Some(engine) = data.engine.as_mut() { + engine.technique.apply_config( + config.point(), + matches!( + setting, + crate::scan_menu::Setting::LineMode | crate::scan_menu::Setting::GridMode + ), + ); + } + data.config = config; + drop(data); + publish::(app); + Ok(()) +} +pub fn restart_point_on_display(app: &AppHandle, next: bool) -> Result<(), String> { + use crate::point_scan_runtime::PointScan; + let (cursor, displays) = crate::display_navigation::displays(app).map_err(|e| e.message)?; + let target = crate::display_navigation::cycle_center(cursor, &displays, next)?; + crate::scan_executor::move_to(target)?; + let controller = app.state::>(); + let mut data = controller.data.lock().unwrap_or_else(|p| p.into_inner()); + let (technique, environment) = PointScan::create(app, data.config.clone())?; + let mut engine = Session::new(technique, data.config.automatic); + engine.action(Action::Select); + data.engine = Some(engine); + data.display = Some(environment); + data.last_tick = Instant::now(); + Ok(()) +} + #[cfg(test)] mod ownership_tests { #[test] diff --git a/src-tauri/src/switch_input/macos.rs b/src-tauri/src/switch_input/macos.rs index 3d204063..1be89320 100644 --- a/src-tauri/src/switch_input/macos.rs +++ b/src-tauri/src/switch_input/macos.rs @@ -122,6 +122,7 @@ impl Capture { events.lost(); return CallbackResult::Keep; } + if crate::input::own_input(event.get_integer_value_field(42)) { return CallbackResult::Keep; } let code = event.get_integer_value_field(9) as u16; if let Some((name, _)) = CODES.iter().find(|(_, c)| *c == code) { if events.key(name, matches!(kind, CGEventType::KeyDown)) { diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs index da50e746..7b93a99f 100644 --- a/src-tauri/src/switch_input/windows.rs +++ b/src-tauri/src/switch_input/windows.rs @@ -111,7 +111,7 @@ unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) - } }); } - if msg == WM_HOTKEY { + if msg == WM_HOTKEY && !crate::input::own_input(unsafe { GetMessageExtraInfo() } as i64) { INPUT.with(|slot| { if let Some(input) = slot.borrow().as_ref() { input.hotkey(wp as i32); @@ -141,7 +141,9 @@ unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) - let key = unsafe { raw.data.keyboard }; // Hotkeys own presses. Raw make events can be repeats or modified keys // that we did not reserve, so they must never start a gesture. - if key.Flags & RI_KEY_BREAK as u16 != 0 { + if key.Flags & RI_KEY_BREAK as u16 != 0 + && !crate::input::own_input(key.ExtraInformation as i64) + { INPUT.with(|slot| { if let Some(input) = slot.borrow().as_ref() { input.raw(key.VKey as i32, true); diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index ace5e27d..fd34f7f0 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -4,7 +4,7 @@ use std::process::Command; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; -use enigo::{Enigo, Settings}; +use enigo::Enigo; use tauri::{AppHandle, Manager}; use windows::core::{IInspectable, Ref, GUID, HSTRING}; use windows::Devices::Bluetooth::BluetoothError; @@ -1059,7 +1059,7 @@ async fn start_gatt( })) .map_err(|error| error.to_string())?; - let input = Enigo::new(&Settings::default()) + let input = Enigo::new(&crate::input::injection_settings()) .map_err(|_| "Windows input injection could not initialize.".to_string())?; if !generation_is_current(&lifecycle, generation) { notifications.shutdown();