From cd03a4e3de0712005ab664085cb5368efe9b5850 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 15 Sep 2026 16:04:45 +0100 Subject: [PATCH 1/2] fix: keep cursor overlay hidden throughout point scanning --- docs/point-scan.md | 2 + src-tauri/src/overlay.rs | 372 ++++++++++++++++++++++++++++-- src-tauri/src/overlay_macos.rs | 44 ++-- src-tauri/src/overlay_windows.rs | 21 +- src-tauri/src/scanning_runtime.rs | 65 +++++- 5 files changed, 455 insertions(+), 49 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 524faaf9..da9dc2f1 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -1,5 +1,7 @@ # Native point scan +The cursor overlay is hidden before a scan appears and stays hidden through pauses, hold prompts, action menus, auto-selection and drag execution. Local and remote scans use the same handoff on Windows and macOS. Once all scan windows are hidden, the cursor resumes its configured visibility: while-controlling mode restores an eligible active-session marker, while on-input mode waits for new pointer input. Old click, scroll and dwell feedback is not replayed. Merely arming scanning does not hide the cursor; the system pointer and modifier-key overlay are unchanged. + Point scan ports the Android line-only and grid-then-line techniques to Switchify PC. The reference source is `switchifyapp/switchify-android` commit `856720d8747e2f3d1724a572bf754ffae05df299`, especially `PointScanLineManager`, `PointScanBlockManager`, and `ContinuousLineSpeedUtils`. Open **Settings → Switches** to add named keyboard switches and assign normal and hold actions. There is no on/off control: scanning is armed whenever the saved switches cover the current mode (Select for automatic scanning; Select, Next and Previous for manual) and the environment allows it. The runtime re-arms after a save, after key learning, after Escape, when an Android session ends, and retries a failed key reservation every two seconds. Fresh installs have no assignments; old point-scan keys migrate once. See [switch assignments](switches.md). Scanning arms at startup, so assigned keys are reserved from launch. diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 22402d99..0b67d5b5 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -1,4 +1,5 @@ use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::AppHandle; @@ -64,39 +65,106 @@ impl CursorOverlayVisualTokens { #[derive(Clone)] pub struct CursorOverlay { sender: Sender, + gate: VisibilityGate, +} + +/// Shared with the native hosts: checks happen at presentation time, not just +/// when work is queued. Never hold this lock while dispatching to AppKit. +#[derive(Clone, Default)] +pub(crate) struct VisibilityGate(Arc>); +#[derive(Default)] +struct Visibility { + epoch: u64, + suppressed: bool, + hidden: bool, +} +impl VisibilityGate { + pub(crate) fn present( + &self, + epoch: u64, + render: impl FnOnce() -> Result<(), String>, + ) -> Result<(), String> { + let state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + if state.epoch == epoch && !state.suppressed { + render() + } else { + Ok(()) + } + } + pub(crate) fn hide(&self, epoch: Option, hide: impl FnOnce()) { + let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + hide(); + if epoch == Some(state.epoch) && state.suppressed { + state.hidden = true; + } + } } impl CursorOverlay { pub fn install(app: AppHandle, shared: SharedModel) -> Self { let (sender, receiver) = mpsc::channel(); - platform::spawn(app, shared, receiver); - Self { sender } + let gate = VisibilityGate::default(); + platform::spawn(app, shared, receiver, gate.clone()); + Self { sender, gate } + } + + fn send(&self, command: Command) { + let state = self.gate.0.lock().unwrap_or_else(|p| p.into_inner()); + let _ = self + .sender + .send(Command::Scoped(state.epoch, Box::new(command))); + } + + pub(crate) fn suppress_for_scan(&self) -> Result { + let mut state = self.gate.0.lock().unwrap_or_else(|p| p.into_inner()); + state.epoch += 1; + state.suppressed = true; + state.hidden = false; + self.sender + .send(Command::Suppress(state.epoch)) + .map_err(|_| "Cursor overlay is unavailable.".to_string())?; + Ok(state.epoch) + } + + pub(crate) fn scan_ready(&self, epoch: u64) -> bool { + let state = self.gate.0.lock().unwrap_or_else(|p| p.into_inner()); + state.epoch == epoch && state.suppressed && state.hidden + } + + /// Called only after all scanner windows have been hidden. + pub(crate) fn release_scan(&self, epoch: u64) { + let mut state = self.gate.0.lock().unwrap_or_else(|p| p.into_inner()); + if state.epoch != epoch || !state.suppressed { + return; + } + state.epoch += 1; + state.suppressed = false; + state.hidden = false; + let _ = self.sender.send(Command::Resume(state.epoch)); } pub fn show(&self, feedback: PointerFeedback, settings: AppSettings) { - let _ = self.sender.send(Command::Show(feedback, settings)); + self.send(Command::Show(feedback, settings)); } pub fn mark_control_active(&self, settings: AppSettings) { - let _ = self.sender.send(Command::MarkControlActive(settings)); + self.send(Command::MarkControlActive(settings)); } pub fn apply_settings(&self, settings: AppSettings) { - let _ = self.sender.send(Command::ApplySettings(settings)); + self.send(Command::ApplySettings(settings)); } pub fn hide_for_typing(&self) { - let _ = self.sender.send(Command::HideForTyping); + self.send(Command::HideForTyping); } pub fn show_dwell(&self, permille: u16, settings: AppSettings) { - let _ = self - .sender - .send(Command::ShowDwell(permille.min(1000), settings)); + self.send(Command::ShowDwell(permille.min(1000), settings)); } pub fn end_dwell(&self) { - let _ = self.sender.send(Command::EndDwell); + self.send(Command::EndDwell); } pub fn begin_repeat( @@ -107,7 +175,7 @@ impl CursorOverlay { dragging: bool, settings: AppSettings, ) { - let _ = self.sender.send(Command::BeginRepeat( + self.send(Command::BeginRepeat( generation, command, accelerated, @@ -117,15 +185,18 @@ impl CursorOverlay { } pub fn end_repeat(&self, generation: u64) { - let _ = self.sender.send(Command::EndRepeat(generation)); + self.send(Command::EndRepeat(generation)); } pub fn end_session(&self) { - let _ = self.sender.send(Command::EndSession); + self.send(Command::EndSession); } } pub(crate) enum Command { + Scoped(u64, Box), + Suppress(u64), + Resume(u64), Show(PointerFeedback, AppSettings), MarkControlActive(AppSettings), ApplySettings(AppSettings), @@ -166,6 +237,23 @@ pub(crate) struct OverlayEngine { } impl OverlayEngine { + fn clear_scan_feedback(&mut self) { + self.feedback = None; + self.drag_active = false; + self.repeat_generation = None; + self.dwell_active = false; + self.deadline = None; + self.visible = false; + } + + fn resume_after_scan(&mut self, now: Instant) -> Update { + self.clear_scan_feedback(); + if self.control_active { + self.handle(Command::MarkControlActive(self.settings.clone()), now) + } else { + Update::None + } + } pub(crate) fn new(now: Instant) -> Self { Self { settings: AppSettings::default(), @@ -183,6 +271,9 @@ impl OverlayEngine { pub(crate) fn handle(&mut self, command: Command, now: Instant) -> Update { match command { + Command::Scoped(_, _) | Command::Suppress(_) | Command::Resume(_) => { + unreachable!("handled by overlay worker") + } Command::Show(feedback, settings) => { self.typing_suppressed = false; self.settings = settings; @@ -722,33 +813,65 @@ fn draw_scroll(pixmap: &mut Pixmap, center: f32, unit: f32, color: [u8; 3], dx: } pub(crate) fn run_loop( - mut render: impl FnMut(&Frame) -> Result<(), String>, - mut hide: impl FnMut(), + mut render: impl FnMut(&Frame, u64) -> Result<(), String>, + mut hide: impl FnMut(Option), mut service_platform_events: impl FnMut() -> bool, receiver: Receiver, + gate: VisibilityGate, ) { let mut engine = OverlayEngine::new(Instant::now()); loop { if !service_platform_events() { - hide(); + hide(None); break; } - let update = match receiver.recv_timeout(Duration::from_millis(25)) { + let command = receiver.recv_timeout(Duration::from_millis(25)); + let (epoch, suppressed) = { + let state = gate.0.lock().unwrap_or_else(|p| p.into_inner()); + (state.epoch, state.suppressed) + }; + let update = match command { + Ok(Command::Suppress(token)) => { + engine.clear_scan_feedback(); + hide(Some(token)); + continue; + } + Ok(Command::Resume(token)) => { + if token != epoch || suppressed { + continue; + } + engine.resume_after_scan(Instant::now()) + } + Ok(Command::Scoped(token, command)) => { + let update = engine.handle(*command, Instant::now()); + if suppressed || token != epoch { + engine.clear_scan_feedback(); + Update::None + } else { + update + } + } Ok(command) => engine.handle(command, Instant::now()), Err(mpsc::RecvTimeoutError::Timeout) => engine.tick(Instant::now()), Err(mpsc::RecvTimeoutError::Disconnected) => Update::Shutdown, }; + let update = if suppressed && matches!(update, Update::Render(_)) { + engine.clear_scan_feedback(); + Update::None + } else { + update + }; match update { Update::Render(frame) => { - if render(&frame).is_err() { - hide(); + if render(&frame, epoch).is_err() { + hide(None); break; } } - Update::Hide => hide(), + Update::Hide => hide(None), Update::None => {} Update::Shutdown => { - hide(); + hide(None); break; } } @@ -759,6 +882,200 @@ pub(crate) fn run_loop( mod tests { use super::*; + fn test_overlay() -> (CursorOverlay, Receiver) { + let (sender, receiver) = mpsc::channel(); + ( + CursorOverlay { + sender, + gate: VisibilityGate::default(), + }, + receiver, + ) + } + + #[test] + fn native_hide_is_acknowledged_before_scanning_and_stale_frames_cannot_return() { + let (overlay, _receiver) = test_overlay(); + let visible = std::cell::Cell::new(false); + overlay + .gate + .present(0, || { + visible.set(true); + Ok(()) + }) + .unwrap(); + let first = overlay.suppress_for_scan().unwrap(); + assert!(!overlay.scan_ready(first)); + overlay.gate.hide(Some(first), || visible.set(false)); + assert!(overlay.scan_ready(first)); + assert!(!visible.get()); + // A native callback queued before acquisition cannot render afterward. + overlay + .gate + .present(0, || { + visible.set(true); + Ok(()) + }) + .unwrap(); + assert!(!visible.get()); + overlay.release_scan(first); + let second = overlay.suppress_for_scan().unwrap(); + overlay.release_scan(first); + overlay.gate.hide(Some(first), || {}); + assert!(!overlay.scan_ready(second)); + overlay.gate.hide(Some(second), || {}); + assert!(overlay.scan_ready(second)); + overlay + .gate + .present(first, || { + visible.set(true); + Ok(()) + }) + .unwrap(); + assert!(!visible.get()); + } + + #[test] + fn queued_feedback_is_discarded_and_only_persistent_cursor_is_restored() { + for visibility in ["onInput", "whileControlling"] { + let (overlay, receiver) = test_overlay(); + let settings = AppSettings { + cursor_overlay_visibility: visibility.into(), + ..Default::default() + }; + overlay.mark_control_active(settings.clone()); + let token = overlay.suppress_for_scan().unwrap(); + overlay.show( + PointerFeedback::Click { + button: crate::protocol::MouseButton::Left, + count: 1, + }, + settings.clone(), + ); + overlay.show_dwell(800, settings.clone()); + overlay.begin_repeat( + 1, + RepeatCommand::Scroll { dx: 0, dy: 1 }, + false, + false, + settings, + ); + let gate = overlay.gate.clone(); + gate.hide(Some(token), || {}); + overlay.release_scan(token); + drop(overlay); + let mut frames = Vec::new(); + run_loop( + |frame, epoch| { + gate.present(epoch, || { + frames.push(frame.feedback); + Ok(()) + }) + }, + |epoch| gate.hide(epoch, || {}), + || true, + receiver, + gate.clone(), + ); + if visibility == "whileControlling" { + assert_eq!(frames, [PointerFeedback::Move]); + } else { + assert!(frames.is_empty()); + } + } + } + + #[test] + fn disconnect_typing_and_disabled_preferences_prevent_restoration() { + for command in [ + Command::EndSession, + Command::HideForTyping, + Command::ApplySettings(AppSettings { + cursor_overlay_enabled: false, + ..Default::default() + }), + ] { + let (overlay, receiver) = test_overlay(); + overlay.mark_control_active(AppSettings::default()); + let token = overlay.suppress_for_scan().unwrap(); + overlay.send(command); + let gate = overlay.gate.clone(); + gate.hide(Some(token), || {}); + overlay.release_scan(token); + drop(overlay); + run_loop( + |_, _| panic!("ineligible cursor was restored"), + |epoch| gate.hide(epoch, || {}), + || true, + receiver, + gate.clone(), + ); + } + } + + #[test] + fn fake_native_hosts_never_overlap_during_handoff_and_restore_fresh_input() { + use std::cell::Cell; + let (overlay, receiver) = test_overlay(); + let gate = overlay.gate.clone(); + let cursor_visible = Cell::new(false); + let scan_visible = Cell::new(false); + let token = Cell::new(0); + let step = Cell::new(0); + let mut frames = Vec::new(); + run_loop( + |frame, epoch| { + gate.present(epoch, || { + assert!(!scan_visible.get(), "cursor rendered over scanning"); + cursor_visible.set(true); + frames.push(frame.feedback); + Ok(()) + }) + }, + |epoch| gate.hide(epoch, || cursor_visible.set(false)), + || { + let current = step.get(); + step.set(current + 1); + match current { + 0 => overlay.show(PointerFeedback::Move, AppSettings::default()), + 1 => { + assert!(cursor_visible.get()); + token.set(overlay.suppress_for_scan().unwrap()); + } + 2 => { + assert!(overlay.scan_ready(token.get())); + assert!(!cursor_visible.get()); + scan_visible.set(true); + overlay.show(PointerFeedback::Drag, AppSettings::default()); + } + 3 => overlay.show_dwell(500, AppSettings::default()), + 4 => overlay.apply_settings(AppSettings::default()), + 5 => { + assert!(!cursor_visible.get()); + scan_visible.set(false); + overlay.release_scan(token.get()); + } + 6 => overlay.show( + PointerFeedback::Scroll { dx: 0, dy: 1 }, + AppSettings::default(), + ), + _ => return false, + } + true + }, + receiver, + gate.clone(), + ); + assert_eq!( + frames, + [ + PointerFeedback::Move, + PointerFeedback::Move, + PointerFeedback::Scroll { dx: 0, dy: 1 } + ] + ); + } + fn ring_frame(feedback: PointerFeedback) -> Frame { Frame { feedback, @@ -1390,13 +1707,14 @@ mod tests { let mut service_count = 0; run_loop( - |_| Ok(()), - || {}, + |_, _| Ok(()), + |_| {}, || { service_count += 1; true }, receiver, + VisibilityGate::default(), ); assert_eq!(service_count, 1); @@ -1407,7 +1725,13 @@ mod tests { let (_sender, receiver) = mpsc::channel(); let hidden = std::cell::Cell::new(false); - run_loop(|_| Ok(()), || hidden.set(true), || false, receiver); + run_loop( + |_, _| Ok(()), + |_| hidden.set(true), + || false, + receiver, + VisibilityGate::default(), + ); assert!(hidden.get()); } diff --git a/src-tauri/src/overlay_macos.rs b/src-tauri/src/overlay_macos.rs index c0152682..fd9a5507 100644 --- a/src-tauri/src/overlay_macos.rs +++ b/src-tauri/src/overlay_macos.rs @@ -13,7 +13,7 @@ use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize}; use tauri::AppHandle; use crate::macos_overlay_window; -use crate::overlay::{render_marker, run_loop, Command, Frame}; +use crate::overlay::{render_marker, run_loop, Command, Frame, VisibilityGate}; use crate::state::{emit_state, set_activity, ActivityKind, SharedModel}; // With AlphaNonpremultiplied and AlphaFirst both absent, AppKit expects the @@ -24,7 +24,12 @@ thread_local! { static HOST: RefCell> = const { RefCell::new(None) }; } -pub(super) fn spawn(app: AppHandle, shared: SharedModel, receiver: Receiver) { +pub(super) fn spawn( + app: AppHandle, + shared: SharedModel, + receiver: Receiver, + gate: VisibilityGate, +) { let Some(mtm) = MainThreadMarker::new() else { report_failure(&app, &shared, "the AppKit main thread is unavailable"); return; @@ -43,17 +48,24 @@ pub(super) fn spawn(app: AppHandle, shared: SharedModel, receiver: Receiver) { +pub(super) fn spawn( + app: AppHandle, + shared: SharedModel, + receiver: Receiver, + gate: VisibilityGate, +) { thread::Builder::new() .name("Switchify cursor overlay".into()) .spawn(move || { @@ -41,16 +46,17 @@ pub(super) fn spawn(app: AppHandle, shared: SharedModel, receiver: Receiver { data: Mutex>, } struct Data { + cursor_suppression: Option<(u64, Instant)>, config: A::Config, engine: Option>, display: Option, @@ -93,6 +94,7 @@ impl Controller { halted: AtomicBool::new(false), generation: AtomicU64::new(0), data: Mutex::new(Data { + cursor_suppression: None, config, engine: None, display: None, @@ -205,6 +207,16 @@ fn reset_scanner(app: &AppHandle, message: &str) { } }); hide_prompt(); + let token = c + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .cursor_suppression + .take(); + if let Some((token, _)) = token { + app.state::() + .release_scan(token); + } publish::(app); } /// Pauses scanning so settings can be saved. Must run on the main thread, as @@ -362,6 +374,18 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64, re disable::(app, "Escape pressed. Scanning reset."); return; } + // Do not act on a scan the user cannot see during the native handoff. + if c.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .cursor_suppression + .is_some_and(|(token, _)| { + !app.state::() + .scan_ready(token) + }) + { + return; + } hide_prompt(); let result = (|| -> Result<(), String> { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); @@ -465,7 +489,26 @@ fn render( prompt: Option<&crate::switch_gestures::Prompt>, ) -> Result<(), String> { let c = app.state::>(); - let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + let cursor = app.state::(); + let active = d.engine.as_ref().is_some_and(Session::active) || prompt.is_some(); + if active { + let (token, started) = match d.cursor_suppression { + Some(lease) => lease, + None => { + let lease = (cursor.suppress_for_scan()?, Instant::now()); + d.cursor_suppression = Some(lease); + lease + } + }; + if !cursor.scan_ready(token) { + if started.elapsed() >= std::time::Duration::from_secs(2) { + return Err("Cursor overlay could not be hidden for scanning.".into()); + } + d.last_tick = Instant::now(); + return Ok(()); + } + } let frame = d .engine .as_ref() @@ -479,7 +522,15 @@ fn render( })?; render_tiles(&frame.tiles)?; render_countdown(frame.countdown.as_ref())?; - render_label(frame.label_for_prompt(prompt.is_some()), &frame.tiles) + render_label(frame.label_for_prompt(prompt.is_some()), &frame.tiles)?; + show_prompt(app, prompt)?; + if !active { + // Native scanning windows have all been hidden before the cursor can return. + if let Some((token, _)) = d.cursor_suppression.take() { + cursor.release_scan(token); + } + } + Ok(()) } fn tick(app: &AppHandle) { let c = app.state::>(); @@ -634,7 +685,14 @@ fn tick(app: &AppHandle) { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); A::validate_environment(app, d.display.as_ref())?; let now = Instant::now(); - let elapsed = now.duration_since(d.last_tick).as_millis() as u64; + let elapsed = if d.cursor_suppression.is_some_and(|(token, _)| { + !app.state::() + .scan_ready(token) + }) { + 0 + } else { + now.duration_since(d.last_tick).as_millis() as u64 + }; d.last_tick = now; let held = d.pressed.held(); let prompt = d.pressed.prompt(now_ms); @@ -657,7 +715,6 @@ fn tick(app: &AppHandle) { if phase_changed { publish::(app); } - show_prompt(app, prompt.as_ref())?; render::(app, prompt.as_ref()) })(); if let Err(error) = result { From c02ede8f55f448cd76f7712fcbdb6f54b7fa5fc2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 15 Sep 2026 16:07:15 +0100 Subject: [PATCH 2/2] fix: hide partially rendered scan windows before cursor restore --- src-tauri/src/scanning_runtime.rs | 75 ++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 06f8711b..65535ba1 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -199,14 +199,8 @@ fn reset_scanner(app: &AppHandle, message: &str) { c.data.lock().unwrap_or_else(|p| p.into_inner()).message = "Input cleanup will be retried before scanning resumes.".into(); } - let _ = render_tiles(&[]); app.state::().stop(); - HOST.with(|host| { - if let Some(host) = host.borrow_mut().as_mut() { - host.hide(); - } - }); - hide_prompt(); + hide_scan_visuals(); let token = c .data .lock() @@ -423,13 +417,7 @@ fn dispatch( if !c.enabled.load(Ordering::SeqCst) || !input_active(app, input_generation, remote) { return Err("Scan action was cancelled.".into()); } - render_tiles(&[])?; - hide_prompt(); - HOST.with(|slot| { - if let Some(host) = slot.borrow_mut().as_mut() { - host.hide(); - } - }); + hide_scan_visuals(); if let Err(error) = A::activate(app, request) { A::cleanup(app)?; let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); @@ -464,6 +452,37 @@ fn render_countdown(countdown: Option<&crate::scanning::Countdown>) -> Result<() Ok(()) }) } + +// Rendering can fail after a native window is shown but before its cache is +// committed. Cleanup must never rely on that cache to decide whether to hide. +fn clear_cached_visual(state: &mut (H, C), hide: impl FnOnce(&mut H)) { + hide(&mut state.0); + state.1 = C::default(); +} + +fn hide_scan_visuals() { + for slot in [&HOST, &PROMPT, &LABEL] { + slot.with(|host| { + if let Some(host) = host.borrow_mut().as_mut() { + host.hide(); + } + }); + } + TILES.with(|slot| { + clear_cached_visual(&mut slot.borrow_mut(), |hosts| { + for host in hosts { + host.hide(); + } + }) + }); + COUNTDOWN.with(|slot| { + clear_cached_visual(&mut slot.borrow_mut(), |host| { + if let Some(host) = host { + host.hide(); + } + }) + }); +} fn render_tiles(tiles: &[crate::scanning::FrameTile]) -> Result<(), String> { TILES.with(|slot| { let mut slot = slot.borrow_mut(); @@ -492,6 +511,13 @@ fn render( let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); let cursor = app.state::(); let active = d.engine.as_ref().is_some_and(Session::active) || prompt.is_some(); + if !active { + hide_scan_visuals(); + if let Some((token, _)) = d.cursor_suppression.take() { + cursor.release_scan(token); + } + return Ok(()); + } if active { let (token, started) = match d.cursor_suppression { Some(lease) => lease, @@ -524,12 +550,6 @@ fn render( render_countdown(frame.countdown.as_ref())?; render_label(frame.label_for_prompt(prompt.is_some()), &frame.tiles)?; show_prompt(app, prompt)?; - if !active { - // Native scanning windows have all been hidden before the cursor can return. - if let Some((token, _)) = d.cursor_suppression.take() { - cursor.release_scan(token); - } - } Ok(()) } fn tick(app: &AppHandle) { @@ -889,6 +909,21 @@ pub fn restart_point_on_display(app: &AppHandle, next: bool) -> Result<(), Strin #[cfg(test)] mod ownership_tests { + #[test] + fn failed_partial_presentations_are_hidden_even_with_empty_caches() { + let mut tiles = (vec![true, true, false], Vec::::new()); + super::clear_cached_visual(&mut tiles, |hosts| hosts.fill(false)); + assert!(tiles.0.iter().all(|visible| !visible)); + assert!(tiles.1.is_empty()); + let mut countdown = (Some(true), None::); + super::clear_cached_visual(&mut countdown, |host| { + if let Some(visible) = host { + *visible = false; + } + }); + assert_eq!(countdown, (Some(false), None)); + } + #[test] fn stopped_remote_input_cannot_fall_back_to_a_matching_local_generation() { assert!(!super::source_is_current(true, false, true));